Skip to content

Commit

Permalink
feat(export invoices): CSV export (#2248)
Browse files Browse the repository at this point in the history
## Roadmap Task

👉
https://getlago.canny.io/feature-requests/p/ability-to-export-data-from-the-user-interface

## Context

In order to effortlessly download export of invoices by non-technical
users, it is required to extend the invoices filtering capabilities.

## Description

This change adds the actual CSV export for invoices, also notifies the
user through the mailer when the file is ready to download.
  • Loading branch information
ancorcruz committed Jul 8, 2024
1 parent 7b4d64a commit e79d638
Show file tree
Hide file tree
Showing 9 changed files with 415 additions and 9 deletions.
15 changes: 14 additions & 1 deletion app/models/data_export.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
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_attached :file

validates :resource_type, :resource_query, presence: true
validates :resource_type, presence: true
validates :format, presence: true, inclusion: {in: EXPORT_FORMATS}
validates :status, presence: true, inclusion: {in: STATUSES}

Expand All @@ -18,6 +19,18 @@ class DataExport < ApplicationRecord

delegate :user, to: :membership

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

Expand Down
4 changes: 2 additions & 2 deletions app/services/data_exports/create_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ def initialize(organization:, user:, format:, resource_type:, resource_query:)
@user = user
@format = format
@resource_type = resource_type
@resource_query = resource_query
@resource_query = resource_query || {}

super(user)
end

def call
data_export = DataExport.create(
data_export = DataExport.create!(
organization:,
membership:,
format:,
Expand Down
99 changes: 99 additions & 0 deletions app/services/data_exports/csv/invoices.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# frozen_string_literal: true

require 'csv'
require 'forwardable'

module DataExports
module Csv
class Invoices < BaseService
extend Forwardable

def initialize(data_export:, serializer_klass: V1::InvoiceSerializer)
@data_export = data_export
@serializer_klass = serializer_klass
end

def call
::CSV.generate(headers: true) do |csv|
csv << headers

query.invoices.find_each do |invoice|
serialized_invoice = serializer_klass
.new(invoice, includes: %i[customer])
.serialize

csv << [
serialized_invoice[:lago_id],
serialized_invoice[:sequential_id],
serialized_invoice[:issuing_date],
serialized_invoice[:customer][:lago_id],
serialized_invoice[:customer][:external_id],
serialized_invoice[:customer][:country],
serialized_invoice[:customer][:tax_identification_number],
serialized_invoice[:number],
serialized_invoice[:total_amount_cents],
serialized_invoice[:currency],
serialized_invoice[:invoice_type],
serialized_invoice[:payment_status],
serialized_invoice[:status],
serialized_invoice[:file_url],
serialized_invoice[:taxes_amount_cents],
serialized_invoice[:credit_notes_amount_cents],
serialized_invoice[:prepaid_credit_amount_cents],
serialized_invoice[:coupons_amount_cents],
serialized_invoice[:payment_due_date],
serialized_invoice[:payment_dispute_lost_at],
serialized_invoice[:payment_overdue]
]
end
end
end

private

attr_reader :data_export, :serializer_klass

def_delegators :data_export, :organization, :resource_query

def headers
%w[
lago_id
sequential_id
issuing_date
customer_lago_id
customer_external_id
customer_country
customer_tax_identification_number
invoince_number
total_amount_cents
currency
invoice_type
payment_status
status
file_url
taxes_amount_cents
credit_notes_amount_cents
prepaid_credit_amount_cents
coupons_amount_cents
payment_due_date
payment_dispute_lost_at
payment_overdue
]
end

def query
search_term = resource_query["search_term"]
status = resource_query["status"]
filters = resource_query.except("search_term", "status")

InvoicesQuery.new(organization: organization).call(
search_term:,
status:,
page: nil,
limit: nil,
filters:
)
end
end
end
end
48 changes: 48 additions & 0 deletions app/services/data_exports/export_resources_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,55 @@

module DataExports
class ExportResourcesService < BaseService
EXPIRED_FAILURE_MESSAGE = 'Data Export already expired'
PROCESSED_FAILURE_MESSAGE = 'Data Export already processed'

def initialize(data_export:)
@data_export = data_export

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!

data_export.file.attach(
io: StringIO.new(file_data),
filename:,
key: "data_exports/#{data_export.id}.#{data_export.format}",
content_type:
)

data_export.completed!

DataExportMailer.with(data_export:).completed.deliver_later

result.data_export = data_export
result
rescue => e
data_export.failed!
result.service_failure!(code: e.message, message: e.full_message)
end

private

attr_reader :data_export

def file_data
case data_export.resource_type
when "invoices" then Csv::Invoices.call(data_export:)
end
end

def filename
"#{data_export.resource_type}_export_#{Time.zone.now.to_i}.#{data_export.format}"
end

def content_type
'text/csv'
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class RemoveDataExportsResourceQueryNullConstraint < ActiveRecord::Migration[7.1]
def change
change_column_null :data_exports, :resource_query, true
end
end
6 changes: 2 additions & 4 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 29 additions & 1 deletion spec/models/data_export_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,37 @@

it { is_expected.to validate_presence_of(:format) }
it { is_expected.to validate_presence_of(:resource_type) }
it { is_expected.to validate_presence_of(:resource_query) }
it { is_expected.to validate_presence_of(:status) }

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 }

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? }

Expand Down
Loading

0 comments on commit e79d638

Please sign in to comment.