From cf65948c6b672bf11ee457b17dd143328e718c28 Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Wed, 26 Aug 2026 10:00:26 +0200 Subject: [PATCH] feat(orders): add order resource --- lib/lago/api/client.rb | 5 + lib/lago/api/resources/order.rb | 39 +++++++ spec/fixtures/api/executed_order.json | 36 ++++++ spec/fixtures/api/order.json | 36 ++++++ spec/fixtures/api/orders_index.json | 61 ++++++++++ spec/lago/api/resources/order_spec.rb | 158 ++++++++++++++++++++++++++ 6 files changed, 335 insertions(+) create mode 100644 lib/lago/api/resources/order.rb create mode 100644 spec/fixtures/api/executed_order.json create mode 100644 spec/fixtures/api/order.json create mode 100644 spec/fixtures/api/orders_index.json create mode 100644 spec/lago/api/resources/order_spec.rb diff --git a/lib/lago/api/client.rb b/lib/lago/api/client.rb index 0046407e..20ebefca 100644 --- a/lib/lago/api/client.rb +++ b/lib/lago/api/client.rb @@ -33,6 +33,7 @@ require 'lago/api/resources/invoiced_usage' require 'lago/api/resources/mrr' require 'lago/api/resources/order_form' +require 'lago/api/resources/order' require 'lago/api/resources/organization' require 'lago/api/resources/overdue_balance' require 'lago/api/resources/payment' @@ -192,6 +193,10 @@ def order_forms Resources::OrderForm.new(self) end + def orders + Resources::Order.new(self) + end + def organizations Resources::Organization.new(self) end diff --git a/lib/lago/api/resources/order.rb b/lib/lago/api/resources/order.rb new file mode 100644 index 00000000..e29898ff --- /dev/null +++ b/lib/lago/api/resources/order.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'lago/api/resources/base' + +module Lago + module Api + module Resources + class Order < Base + undef_method :create, :update, :destroy + + def api_resource + 'orders' + end + + def root_name + 'order' + end + + def execute(order_id, params = {}) + path = "/api/v1/orders/#{order_id}/execute" + payload = whitelist_params(params) + response = connection.post(payload, path)[root_name] + + JSON.parse(response.to_json, object_class: OpenStruct) + end + + def whitelist_params(params) + result = { + execution_mode: params[:execution_mode], + }.compact + + return {} if result.empty? + + { root_name => result } + end + end + end + end +end diff --git a/spec/fixtures/api/executed_order.json b/spec/fixtures/api/executed_order.json new file mode 100644 index 00000000..5fd1051c --- /dev/null +++ b/spec/fixtures/api/executed_order.json @@ -0,0 +1,36 @@ +{ + "order": { + "lago_id": "cc33cc33-cc33-cc33-cc33-cc33cc33cc33", + "number": "OR-2026-0001", + "status": "executed", + "order_type": "subscription_creation", + "execution_mode": "execute_in_lago", + "currency": "EUR", + "executed_at": "2026-07-01T00:00:00Z", + "execution_record": { + "executed_at": "2026-07-01T00:00:00Z", + "execution_mode": "execute_in_lago", + "invoice_id": null, + "subscription_ids": ["dd44dd44-dd44-dd44-dd44-dd44dd44dd44"], + "terminated_subscription_ids": [], + "applied_coupon_ids": ["ee55ee55-ee55-ee55-ee55-ee55ee55ee55"], + "wallet_ids": [], + "errors": [] + }, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_order_form_id": "aa11aa11-aa11-aa11-aa11-aa11aa11aa11", + "created_at": "2026-05-02T10:15:00Z", + "updated_at": "2026-07-01T00:00:00Z", + "billing_snapshot": { + "plans": [ + { + "id": "7a567a56-7a56-7a56-7a56-7a567a567a56", + "localId": "b5c1e2a4-4e1e-4a7f-9f0e-9c1a0c7e1f2b", + "type": "plan", + "payload": { "code": "premium_plan" } + } + ] + } + } +} diff --git a/spec/fixtures/api/order.json b/spec/fixtures/api/order.json new file mode 100644 index 00000000..97043a1d --- /dev/null +++ b/spec/fixtures/api/order.json @@ -0,0 +1,36 @@ +{ + "order": { + "lago_id": "cc33cc33-cc33-cc33-cc33-cc33cc33cc33", + "number": "OR-2026-0001", + "status": "created", + "order_type": "subscription_creation", + "execution_mode": "execute_in_lago", + "currency": "EUR", + "executed_at": null, + "execution_record": { + "executed_at": null, + "execution_mode": "execute_in_lago", + "invoice_id": null, + "subscription_ids": [], + "terminated_subscription_ids": [], + "applied_coupon_ids": [], + "wallet_ids": [], + "errors": [] + }, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_order_form_id": "aa11aa11-aa11-aa11-aa11-aa11aa11aa11", + "created_at": "2026-05-02T10:15:00Z", + "updated_at": "2026-05-02T10:15:00Z", + "billing_snapshot": { + "plans": [ + { + "id": "7a567a56-7a56-7a56-7a56-7a567a567a56", + "localId": "b5c1e2a4-4e1e-4a7f-9f0e-9c1a0c7e1f2b", + "type": "plan", + "payload": { "code": "premium_plan" } + } + ] + } + } +} diff --git a/spec/fixtures/api/orders_index.json b/spec/fixtures/api/orders_index.json new file mode 100644 index 00000000..87f8570d --- /dev/null +++ b/spec/fixtures/api/orders_index.json @@ -0,0 +1,61 @@ +{ + "orders": [ + { + "lago_id": "cc33cc33-cc33-cc33-cc33-cc33cc33cc33", + "number": "OR-2026-0001", + "status": "created", + "order_type": "subscription_creation", + "execution_mode": "execute_in_lago", + "currency": "EUR", + "executed_at": null, + "execution_record": { + "executed_at": null, + "execution_mode": "execute_in_lago", + "invoice_id": null, + "subscription_ids": [], + "terminated_subscription_ids": [], + "applied_coupon_ids": [], + "wallet_ids": [], + "errors": [] + }, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_order_form_id": "aa11aa11-aa11-aa11-aa11-aa11aa11aa11", + "created_at": "2026-05-02T10:15:00Z", + "updated_at": "2026-05-02T10:15:00Z", + "billing_snapshot": {} + }, + { + "lago_id": "ff66ff66-ff66-ff66-ff66-ff66ff66ff66", + "number": "OR-2026-0002", + "status": "failed", + "order_type": "one_off", + "execution_mode": null, + "currency": "EUR", + "executed_at": null, + "execution_record": { + "executed_at": null, + "execution_mode": null, + "invoice_id": null, + "subscription_ids": [], + "terminated_subscription_ids": [], + "applied_coupon_ids": [], + "wallet_ids": [], + "errors": ["plan_not_found"] + }, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_order_form_id": "bb22bb22-bb22-bb22-bb22-bb22bb22bb22", + "created_at": "2026-05-03T10:15:00Z", + "updated_at": "2026-05-03T10:15:00Z", + "billing_snapshot": {} + } + ], + "meta": { + "current_page": 1, + "next_page": null, + "prev_page": null, + "total_pages": 1, + "total_count": 2 + } +} diff --git a/spec/lago/api/resources/order_spec.rb b/spec/lago/api/resources/order_spec.rb new file mode 100644 index 00000000..8247712e --- /dev/null +++ b/spec/lago/api/resources/order_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Lago::Api::Resources::Order do + subject(:resource) { described_class.new(client) } + + let(:client) { Lago::Api::Client.new } + + let(:order_response) { load_fixture('order') } + let(:order_id) { JSON.parse(order_response)['order']['lago_id'] } + let(:error_response) do + { + 'status' => 422, + 'error' => 'Unprocessable Entity', + 'message' => 'Validation error on the record', + }.to_json + end + + describe '#get' do + let(:endpoint) { "https://api.getlago.com/api/v1/orders/#{order_id}" } + + context 'when order is successfully fetched' do + before do + stub_request(:get, endpoint) + .to_return(body: order_response, status: 200) + end + + it 'returns an order' do + order = resource.get(order_id) + + expect(order.lago_id).to eq(order_id) + expect(order.number).to eq('OR-2026-0001') + expect(order.status).to eq('created') + expect(order.order_type).to eq('subscription_creation') + expect(order.execution_mode).to eq('execute_in_lago') + expect(order.executed_at).to be_nil + expect(order.execution_record.errors).to eq([]) + expect(order.lago_order_form_id).to eq('aa11aa11-aa11-aa11-aa11-aa11aa11aa11') + expect(order.billing_snapshot.plans.first.payload.code).to eq('premium_plan') + end + end + + context 'when is not found' do + before do + stub_request(:get, endpoint) + .to_return(body: { 'status' => 404, 'error' => 'Not Found' }.to_json, status: 404) + end + + it 'raises an error' do + expect { resource.get(order_id) }.to raise_error Lago::Api::HttpError + end + end + end + + describe '#get_all' do + let(:endpoint) { "https://api.getlago.com/api/v1/orders#{options}" } + let(:options) { '' } + let(:orders_response) { load_fixture('orders_index') } + + context 'when there is no options' do + before do + stub_request(:get, endpoint) + .to_return(body: orders_response, status: 200) + end + + it 'returns orders on the first page' do + response = resource.get_all + + expect(response['orders'].first['number']).to eq('OR-2026-0001') + expect(response['orders'].last['status']).to eq('failed') + expect(response['orders'].last['execution_mode']).to be_nil + expect(response['orders'].last['execution_record']['errors']).to eq(['plan_not_found']) + expect(response['meta']['current_page']).to eq(1) + end + end + + context 'when options are present' do + let(:options_hash) do + { per_page: 2, page: 1, 'status[]' => 'created', 'execution_mode[]' => 'execute_in_lago' } + end + let(:options) { "?#{URI.encode_www_form(options_hash)}" } + + before do + stub_request(:get, endpoint) + .to_return(body: orders_response, status: 200) + end + + it 'returns orders on the selected page' do + response = resource.get_all(options_hash) + + expect(response['orders'].first['number']).to eq('OR-2026-0001') + expect(response['meta']['current_page']).to eq(1) + end + end + end + + describe '#execute' do + let(:endpoint) { "https://api.getlago.com/api/v1/orders/#{order_id}/execute" } + let(:executed_response) { load_fixture('executed_order') } + + context 'when no params are given' do + before do + stub_request(:post, endpoint) + .with(body: {}) + .to_return(body: executed_response, status: 200) + end + + it 'returns the executed order' do + order = resource.execute(order_id) + + expect(order.lago_id).to eq(order_id) + expect(order.status).to eq('executed') + expect(order.executed_at).to eq('2026-07-01T00:00:00Z') + expect(order.execution_record.subscription_ids).to eq(['dd44dd44-dd44-dd44-dd44-dd44dd44dd44']) + expect(order.execution_record.applied_coupon_ids).to eq(['ee55ee55-ee55-ee55-ee55-ee55ee55ee55']) + end + end + + context 'when an execution mode is given' do + let(:params) { { execution_mode: 'execute_in_lago' } } + + before do + stub_request(:post, endpoint) + .with(body: { order: params }) + .to_return(body: executed_response, status: 200) + end + + it 'returns the executed order' do + order = resource.execute(order_id, params) + + expect(order.status).to eq('executed') + end + end + + context 'when the order cannot be executed' do + before do + stub_request(:post, endpoint) + .to_return(body: error_response, status: 422) + end + + it 'raises an error' do + expect { resource.execute(order_id) }.to raise_error Lago::Api::HttpError + end + end + + context 'when a catalog record pinned by the quote is missing' do + before do + stub_request(:post, endpoint) + .to_return(body: { 'status' => 404, 'error' => 'Not Found' }.to_json, status: 404) + end + + it 'raises an error' do + expect { resource.execute(order_id) }.to raise_error Lago::Api::HttpError + end + end + end +end