Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(export invoices): Create data export #2226

Merged
merged 11 commits into from
Jul 2, 2024
9 changes: 9 additions & 0 deletions app/jobs/data_exports/export_resources_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module DataExports
class ExportResourcesJob < ApplicationJob
queue_as :default

def perform(data_export)
ExportResourcesService.call(data_export:).raise_if_error!
end
end
end
21 changes: 21 additions & 0 deletions app/mailers/data_export_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
class DataExportMailer < ApplicationMailer
def completed
@data_export = params[:data_export]
user = @data_export.user

return if @data_export.file.blank?
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: @data_export.resource_type
)
)
end
end
end
30 changes: 29 additions & 1 deletion app/models/data_export.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ class DataExport < ApplicationRecord
EXPORT_FORMATS = %w[csv].freeze
STATUSES = %w[pending processing completed failed].freeze

belongs_to :user
belongs_to :organization
belongs_to :membership

has_one_attached :file

validates :resource_type, :resource_query, presence: true
Expand All @@ -11,4 +13,30 @@ class DataExport < ApplicationRecord

enum format: EXPORT_FORMATS
enum status: STATUSES

delegate :user, to: :membership

def expired?
return false unless expires_at

expires_at < Time.zone.now
end

def filename
return if file.blank?

"#{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: 7.days
)

File.join(ENV['LAGO_API_URL'], blob_path)
end
end
2 changes: 2 additions & 0 deletions app/models/membership.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ class Membership < ApplicationRecord
belongs_to :organization
belongs_to :user

has_many :data_exports

STATUSES = [
:active,
:revoked
Expand Down
1 change: 1 addition & 0 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class Organization < ApplicationRecord
has_many :webhook_endpoints
has_many :webhooks, through: :webhook_endpoints
has_many :cached_aggregations
has_many :data_exports

has_many :stripe_payment_providers, class_name: 'PaymentProviders::StripeProvider'
has_many :gocardless_payment_providers, class_name: 'PaymentProviders::GocardlessProvider'
Expand Down
36 changes: 36 additions & 0 deletions app/services/data_exports/create_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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)

result.data_export = data_export
result
end

private

attr_reader :organization, :user, :format, :resource_type, :resource_query

def membership
user.memberships.find_by(organization: organization)
end
end
end
6 changes: 6 additions & 0 deletions app/services/data_exports/export_resources_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module DataExports
class ExportResourcesService < BaseService
def call
end
end
end
37 changes: 37 additions & 0 deletions app/views/data_export_mailer/completed.slim
Original file line number Diff line number Diff line change
@@ -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')
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')
1 change: 1 addition & 0 deletions config/environments/development.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
address: 'mailhog',
port: 1025
}
config.action_mailer.preview_paths << Rails.root.join("spec/mailers/previews").to_s

Dotenv.load
end
9 changes: 9 additions & 0 deletions config/locales/en/email.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ en:
issue_date: Issue date
refunded_notice: Refunded on %{date}
subject: 'Your credit note from %{organization_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 invoices 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 in ready!
thanks: Thanks,
invoice:
finalized:
download: Download invoice for details
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AddMembershipIdAndOrganizationIdToDataExports < ActiveRecord::Migration[7.1]
def change
add_reference :data_exports, :membership, foreign_key: true, type: :uuid
add_reference :data_exports, :organization, foreign_key: true, type: :uuid
end
end
5 changes: 5 additions & 0 deletions db/migrate/20240628083830_remove_user_id_from_data_exports.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class RemoveUserIdFromDataExports < ActiveRecord::Migration[7.1]
def change
remove_reference :data_exports, :user, null: false, foreign_key: true, type: :uuid
end
end
11 changes: 7 additions & 4 deletions db/schema.rb

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

24 changes: 20 additions & 4 deletions spec/factories/data_exports.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
FactoryBot.define do
factory :data_export do
user
organization
membership { association :membership, organization: organization }

format { 'csv' }
resource_type { "invoices" }
resource_query { {filters: {currency: 'EUR'}} }
status { 'pending' }
file { nil }
expires_at { 7.days.from_now }
started_at { 2.hours.ago }
completed_at { 30.minutes.ago }

trait :processing do
status { 'processing' }
started_at { 2.hours.ago }
end

trait :completed do
status { 'completed' }
started_at { 2.hours.ago }
completed_at { 30.minutes.ago }
expires_at { 7.days.from_now }
file { Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/export.csv")) }
end

trait :expired do
completed
expires_at { 1.day.ago }
end
end
end
3 changes: 3 additions & 0 deletions spec/fixtures/export.csv
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions spec/jobs/data_exports/export_resources_job_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
require 'rails_helper'

RSpec.describe DataExports::ExportResourcesJob, type: :job do
let(:data_export) { create(:data_export) }
let(:result) { BaseService::Result.new }

before do
allow(DataExports::ExportResourcesService)
.to receive(:call)
.with(data_export:)
.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:)
end
end
39 changes: 39 additions & 0 deletions spec/mailers/data_export_mailer_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
require 'rails_helper'

RSpec.describe DataExportMailer, type: :mailer do
subject(:data_export_mailer) { described_class }

let(:data_export) { create(:data_export, :completed) }

describe '#completed' do
let(:mailer) { data_export_mailer.with(data_export:).completed }

specify do
expect(mailer.to).to eq([data_export.user.email])
end

context 'when data export is expired' do
let(:data_export) { create(:data_export, :expired) }

it 'does something' do
expect(mailer.to).to be_nil
end
end

context 'when data export is not completed' do
let(:data_export) { create(:data_export, :processing) }

it 'returns a mailer with nil values' do
expect(mailer.to).to be_nil
end
end

context 'when data export has no attached file' do
let(:data_export) { create(:data_export, :completed, file: nil) }

it 'returns a mailer with nil values' do
expect(mailer.to).to be_nil
end
end
end
end
10 changes: 10 additions & 0 deletions spec/mailers/previews/base_preview_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class BasePreviewMailer < ActionMailer::Preview
def self.call(...)
message = nil
ActiveRecord::Base.transaction do
message = super(...)
raise ActiveRecord::Rollback
end
message
end
end
6 changes: 6 additions & 0 deletions spec/mailers/previews/data_export_mailer_preview.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class DataExportMailerPreview < BasePreviewMailer
def completed
data_export = FactoryBot.create :data_export, :completed
DataExportMailer.with(data_export:).completed
end
end
Loading