From d1c844cef5a18a4f8b2098592762b3b40e2e693d Mon Sep 17 00:00:00 2001 From: LovroColic Date: Mon, 27 Jan 2025 11:19:53 +0100 Subject: [PATCH] feat(preview-invoice): add route and controller action (#3108) ## Context Preview feature enables fetching first Lago invoice. This invoice is not persisted and is calculated on the fly. ## Description This PR is the last one for the feature. It adds route, controller action and includes few fixes based on QA returns --------- Co-authored-by: Oleksandr Chebotarov --- app/controllers/api/v1/invoices_controller.rb | 63 +++++++++++++++++++ app/serializers/v1/invoice_serializer.rb | 14 +++++ .../v1/invoices/applied_tax_serializer.rb | 2 +- app/services/coupons/preview_service.rb | 2 +- .../invoices/preview_context_service.rb | 4 +- app/services/invoices/preview_service.rb | 4 +- config/routes.rb | 1 + .../api/v1/invoices_controller_spec.rb | 46 ++++++++++++++ .../services/invoices/preview_service_spec.rb | 32 ++++++++++ 9 files changed, 164 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/v1/invoices_controller.rb b/app/controllers/api/v1/invoices_controller.rb index 2381f0fdd97..23e4976b65f 100644 --- a/app/controllers/api/v1/invoices_controller.rb +++ b/app/controllers/api/v1/invoices_controller.rb @@ -202,6 +202,31 @@ def sync_salesforce_id end end + def preview + result = Invoices::PreviewContextService.call( + organization: current_organization, + params: preview_params.to_h.deep_symbolize_keys + ) + return render_error_response(result) unless result.success? + + result = Invoices::PreviewService.call( + customer: result.customer, + subscription: result.subscription, + 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 @@ -234,6 +259,44 @@ def update_params ) end + def preview_params + params.permit( + :plan_code, + :billing_time, + :subscription_at, + 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, + shipping_address: [ + :address_line1, + :address_line2, + :city, + :zipcode, + :state, + :country + ], + integration_customers: [ + :integration_type, + :integration_code + ] + ] + ) + end + def sync_salesforce_id_params params.permit( :external_id, diff --git a/app/serializers/v1/invoice_serializer.rb b/app/serializers/v1/invoice_serializer.rb index f2f26044180..0481196cde6 100644 --- a/app/serializers/v1/invoice_serializer.rb +++ b/app/serializers/v1/invoice_serializer.rb @@ -42,6 +42,8 @@ def serialize 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 @@ -63,6 +65,12 @@ def subscriptions ).serialize end + def preview_subscriptions + ::CollectionSerializer.new( + model.subscriptions, ::V1::SubscriptionSerializer, collection_name: 'subscriptions' + ).serialize + end + def fees ::CollectionSerializer.new( model.fees.includes( @@ -80,6 +88,12 @@ def 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 diff --git a/app/serializers/v1/invoices/applied_tax_serializer.rb b/app/serializers/v1/invoices/applied_tax_serializer.rb index dced172d0ea..234c4d51d23 100644 --- a/app/serializers/v1/invoices/applied_tax_serializer.rb +++ b/app/serializers/v1/invoices/applied_tax_serializer.rb @@ -15,7 +15,7 @@ def serialize amount_cents: model.amount_cents, amount_currency: model.amount_currency, fees_amount_cents: model.fees_amount_cents, - created_at: model.created_at.iso8601 + created_at: model&.created_at&.iso8601 } end end diff --git a/app/services/coupons/preview_service.rb b/app/services/coupons/preview_service.rb index 251ed673a9b..1a385787dce 100644 --- a/app/services/coupons/preview_service.rb +++ b/app/services/coupons/preview_service.rb @@ -17,7 +17,7 @@ def call applied_coupons.each do |applied_coupon| break unless invoice.sub_total_excluding_taxes_amount_cents&.positive? - next unless invoice.currency == applied_coupon.amount_currency + next if applied_coupon.coupon.fixed_amount? && invoice.currency != applied_coupon.amount_currency fees = fees(applied_coupon) diff --git a/app/services/invoices/preview_context_service.rb b/app/services/invoices/preview_context_service.rb index 8612d8b4aba..e1141b25451 100644 --- a/app/services/invoices/preview_context_service.rb +++ b/app/services/invoices/preview_context_service.rb @@ -34,6 +34,7 @@ def find_or_build_customer organization: organization, tax_identification_number: customer_params[:tax_identification_number], currency: customer_params[:currency], + timezone: customer_params[:timezone], 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), @@ -73,7 +74,7 @@ def build_subscription subscription_at: params[:subscription_at].presence || Time.current, started_at: params[:subscription_at].presence || Time.current, billing_time:, - created_at: Time.current, + created_at: params[:subscription_at].presence || Time.current, updated_at: Time.current ) end @@ -94,6 +95,7 @@ def find_or_build_applied_coupons coupon || Coupon.new(coupon_attr) end.map do |coupon| AppliedCoupon.new( + id: SecureRandom.uuid, coupon:, customer: result.customer, amount_cents: coupon.amount_cents, diff --git a/app/services/invoices/preview_service.rb b/app/services/invoices/preview_service.rb index e569bba352a..cbfd19db496 100644 --- a/app/services/invoices/preview_service.rb +++ b/app/services/invoices/preview_service.rb @@ -33,6 +33,7 @@ def call compute_tax_and_totals result.invoice = invoice + result.subscription = subscription result end @@ -56,8 +57,9 @@ def date_service def billing_time return @billing_time if defined? @billing_time + return subscription.subscription_at if subscription.plan.pay_in_advance? - ds = Subscriptions::DatesService.new_instance(subscription, Time.current, current_usage: true) + ds = Subscriptions::DatesService.new_instance(subscription, subscription.subscription_at, current_usage: true) @billing_time = ds.end_of_period + 1.day end diff --git a/config/routes.rb b/config/routes.rb index 10be88c1c3a..31452f1c346 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -61,6 +61,7 @@ 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 diff --git a/spec/requests/api/v1/invoices_controller_spec.rb b/spec/requests/api/v1/invoices_controller_spec.rb index b73d8400bdf..0545713af05 100644 --- a/spec/requests/api/v1/invoices_controller_spec.rb +++ b/spec/requests/api/v1/invoices_controller_spec.rb @@ -1008,4 +1008,50 @@ end end end + + describe 'POST /api/v1/invoices/preview' 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 + + 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 + + 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 + end end diff --git a/spec/services/invoices/preview_service_spec.rb b/spec/services/invoices/preview_service_spec.rb index 153396471ac..5fa383b9250 100644 --- a/spec/services/invoices/preview_service_spec.rb +++ b/spec/services/invoices/preview_service_spec.rb @@ -64,6 +64,38 @@ end end + context 'with in advance billing in the future' do + 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.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-03-31') + 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 applied coupons' do let(:applied_coupon) do build(