Skip to content

Commit

Permalink
feat(revshare): missing bits to complete the feature (#3094)
Browse files Browse the repository at this point in the history
## Roadmap

👉 https://getlago.canny.io/feature-requests/p/calculate-revenue-share

 ## Context

Current problem: companies with **partners** selling for them cannot
have a **revenue share** system in Lago.

We want to propose **self-billing** into Lago, a billing arrangement
where the **customer** creates and issues the invoice on **behalf** of
the **supplier** for goods or services received.

 ## Description

* exclude self billed invoices in customer overdue balance calculations
* Add query filters for customer account_type, and invoices and credit
notes self_billed
* Add account_type to graphQL customer portal customer object
* Add account_type filter to graphql customers resolver and Api endpoint
* Add self_billed filter to graphql invoices resolver and Api endpoint
* Add self_billed filter to graphql credit notes resolver and Api
endpoint
* Add self_billed to credit note and fee API serializer
* Update self_billed pdf invoices translations
* Adapt template for self_billed one_off invoices
  • Loading branch information
ancorcruz authored Jan 23, 2025
1 parent 60ddac5 commit bda45af
Show file tree
Hide file tree
Showing 44 changed files with 725 additions and 133 deletions.
13 changes: 13 additions & 0 deletions app/contracts/queries/customers_query_filters_contract.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

module Queries
class CustomersQueryFiltersContract < Dry::Validation::Contract
params do
required(:filters).hash do
optional(:account_type).array(:string, included_in?: Customer::ACCOUNT_TYPES.values)
end

optional(:search_term).maybe(:string)
end
end
end
13 changes: 7 additions & 6 deletions app/controllers/api/v1/credit_notes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,23 +101,24 @@ def index
},
search_term: params[:search_term],
filters: {
amount_from: params[:amount_from],
amount_to: params[:amount_to],
credit_status: params[:credit_status],
currency: params[:currency],
customer_external_id: params[:external_customer_id],
reason: params[:reason],
credit_status: params[:credit_status],
refund_status: params[:refund_status],
invoice_number: params[:invoice_number],
issuing_date_from: (Date.strptime(params[:issuing_date_from]) if valid_date?(params[:issuing_date_from])),
issuing_date_to: (Date.strptime(params[:issuing_date_to]) if valid_date?(params[:issuing_date_to])),
amount_from: params[:amount_from],
amount_to: params[:amount_to]
reason: params[:reason],
refund_status: params[:refund_status],
self_billed: params[:self_billed]
}
)

if result.success?
render(
json: ::CollectionSerializer.new(
result.credit_notes.includes(:items, :applied_taxes),
result.credit_notes.includes(:items, :applied_taxes, :invoice),
::V1::CreditNoteSerializer,
collection_name: "credit_notes",
meta: pagination_metadata(result.credit_notes),
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/api/v1/customers_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ def index
pagination: {
page: params[:page],
limit: params[:per_page] || PER_PAGE
}
},
filters: params.slice(:account_type)
)

if result.success?
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/api/v1/fees_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def index
if result.success?
render(
json: ::CollectionSerializer.new(
result.fees.includes(:applied_taxes),
result.fees.includes(:applied_taxes, :invoice),
::V1::FeeSerializer,
collection_name: 'fees',
meta: pagination_metadata(result.fees),
Expand Down
13 changes: 7 additions & 6 deletions app/controllers/api/v1/invoices_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,18 @@ def index
filters: {
amount_from: params[:amount_from],
amount_to: params[:amount_to],
payment_status: (params[:payment_status] if valid_payment_status?(params[:payment_status])),
payment_dispute_lost: params[:payment_dispute_lost],
payment_overdue: (params[:payment_overdue] if %w[true false].include?(params[:payment_overdue])),
partially_paid: (params[:partially_paid] if %w[true false].include?(params[:partially_paid])),
status: (params[:status] if valid_status?(params[:status])),
currency: params[:currency],
customer_external_id: params[:external_customer_id],
invoice_type: params[:invoice_type],
issuing_date_from: (Date.strptime(params[:issuing_date_from]) if valid_date?(params[:issuing_date_from])),
issuing_date_to: (Date.strptime(params[:issuing_date_to]) if valid_date?(params[:issuing_date_to])),
metadata: params[:metadata]&.permit!.to_h
metadata: params[:metadata]&.permit!.to_h,
partially_paid: (params[:partially_paid] if %w[true false].include?(params[:partially_paid])),
payment_dispute_lost: params[:payment_dispute_lost],
payment_overdue: (params[:payment_overdue] if %w[true false].include?(params[:payment_overdue])),
payment_status: (params[:payment_status] if valid_payment_status?(params[:payment_status])),
self_billed: (params[:self_billed] if %w[true false].include?(params[:self_billed])),
status: (params[:status] if valid_status?(params[:status]))
}
)

Expand Down
12 changes: 8 additions & 4 deletions app/graphql/resolvers/credit_notes_resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ class CreditNotesResolver < Resolvers::BaseResolver

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 :credit_status, [Types::CreditNotes::CreditStatusTypeEnum], required: false
Expand All @@ -18,11 +23,9 @@ class CreditNotesResolver < Resolvers::BaseResolver
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 :limit, Integer, required: false
argument :page, Integer, 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

type Types::CreditNotes::Object.collection_type, null: false

Expand All @@ -41,7 +44,8 @@ def resolve(args)
issuing_date_from: args[:issuing_date_from],
issuing_date_to: args[:issuing_date_to],
reason: args[:reason],
refund_status: args[:refund_status]
refund_status: args[:refund_status],
self_billed: args[:self_billed]
},
pagination: {
page: args[:page],
Expand Down
14 changes: 9 additions & 5 deletions app/graphql/resolvers/customers_resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,22 @@ class CustomersResolver < Resolvers::BaseResolver

argument :limit, Integer, required: false
argument :page, Integer, required: false

argument :search_term, String, required: false

argument :account_type, [Types::Customers::AccountTypeEnum], required: false

type Types::Customers::Object.collection_type, null: false

def resolve(page: nil, limit: nil, search_term: nil)
def resolve(**args)
result = CustomersQuery.call(
organization: current_organization,
search_term:,
search_term: args[:search_term],
pagination: {
page:,
limit:
}
page: args[:page],
limit: args[:limit]
},
filters: args.slice(:account_type)
)

result.customers
Expand Down
21 changes: 12 additions & 9 deletions app/graphql/resolvers/invoices_resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class InvoicesResolver < Resolvers::BaseResolver
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

type Types::Invoices::Object.collection_type, null: false
Expand All @@ -36,13 +37,14 @@ def resolve( # rubocop:disable Metrics/ParameterLists
invoice_type: nil,
issuing_date_from: nil,
issuing_date_to: nil,
page: nil,
limit: nil,
page: nil,
payment_dispute_lost: nil,
payment_overdue: nil,
payment_status: nil,
status: nil,
search_term: nil,
payment_dispute_lost: nil,
payment_overdue: nil
self_billed: nil,
status: nil
)
result = InvoicesQuery.call(
organization: current_organization,
Expand All @@ -51,16 +53,17 @@ def resolve( # rubocop:disable Metrics/ParameterLists
filters: {
amount_from:,
amount_to:,
payment_status:,
payment_dispute_lost:,
payment_overdue:,
status:,
currency:,
customer_external_id:,
customer_id:,
invoice_type:,
issuing_date_from:,
issuing_date_to:
issuing_date_to:,
payment_dispute_lost:,
payment_overdue:,
payment_status:,
self_billed:,
status:
}
)

Expand Down
1 change: 1 addition & 0 deletions app/graphql/types/customer_portal/customers/object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Object < Types::BaseObject

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
Expand Down
2 changes: 1 addition & 1 deletion app/models/customer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ def empty_billing_and_shipping_address?
end

def overdue_balance_cents
invoices.payment_overdue.where(currency:).sum(:total_amount_cents)
invoices.non_self_billed.payment_overdue.where(currency:).sum(:total_amount_cents)
end

def reset_dunning_campaign!
Expand Down
9 changes: 6 additions & 3 deletions app/models/invoice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ class Invoice < ApplicationRecord
.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}
Expand Down Expand Up @@ -402,7 +405,7 @@ def generate_organization_sequential_id
"date_trunc('month', created_at::timestamptz AT TIME ZONE ?)::date = ?",
timezone,
Time.now.in_time_zone(timezone).beginning_of_month.to_date
).where(self_billed: false)
).non_self_billed

result = Invoice.with_advisory_lock(
organization_id,
Expand All @@ -415,7 +418,7 @@ def generate_organization_sequential_id
else
organization
.invoices
.where(self_billed: false)
.non_self_billed
.where.not(organization_sequential_id: 0)
.order(organization_sequential_id: :desc)
.limit(1)
Expand All @@ -437,7 +440,7 @@ def generate_organization_sequential_id
end

def switched_from_customer_numbering?
last_invoice = organization.invoices.where(self_billed: false).order(created_at: :desc).with_generated_number.first
last_invoice = organization.invoices.non_self_billed.order(created_at: :desc).with_generated_number.first

return false unless last_invoice

Expand Down
9 changes: 9 additions & 0 deletions app/queries/credit_notes_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def call
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?

result.credit_notes = credit_notes
result
Expand Down Expand Up @@ -93,6 +94,14 @@ def with_amount_range(scope)
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 issuing_date_from
@issuing_date_from ||= parse_datetime_filter(:issuing_date_from)
end
Expand Down
12 changes: 12 additions & 0 deletions app/queries/customers_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,24 @@

class CustomersQuery < BaseQuery
def call
return result unless validate_filters.success?

customers = base_scope.result
customers = paginate(customers)
customers = apply_consistent_ordering(customers)

customers = with_account_type(customers) if filters.account_type.present?

result.customers = customers
result
end

private

def filters_contract
@filters_contract ||= Queries::CustomersQueryFiltersContract.new
end

def base_scope
Customer.where(organization:).ransack(search_params)
end
Expand All @@ -29,4 +37,8 @@ def search_params
email_cont: search_term
}
end

def with_account_type(scope)
scope.where(account_type: filters.account_type)
end
end
5 changes: 5 additions & 0 deletions app/queries/invoices_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def call
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_self_billed(invoices) unless filters.self_billed.nil?

result.invoices = invoices
result
Expand Down Expand Up @@ -141,6 +142,10 @@ def with_metadata(scope)
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 issuing_date_from
@issuing_date_from ||= parse_datetime_filter(:issuing_date_from)
end
Expand Down
3 changes: 2 additions & 1 deletion app/serializers/v1/credit_note_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ def serialize
taxes_rate: model.taxes_rate,
created_at: model.created_at.iso8601,
updated_at: model.updated_at.iso8601,
file_url: model.file_url
file_url: model.file_url,
self_billed: model.invoice.self_billed
}

payload.merge!(customer) if include?(:customer)
Expand Down
3 changes: 2 additions & 1 deletion app/serializers/v1/fee_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ def serialize
succeeded_at: model.succeeded_at&.iso8601,
failed_at: model.failed_at&.iso8601,
refunded_at: model.refunded_at&.iso8601,
amount_details: model.amount_details
amount_details: model.amount_details,
self_billed: model.invoice&.self_billed
}

payload.merge!(date_boundaries) if model.charge? || model.subscription?
Expand Down
47 changes: 47 additions & 0 deletions app/views/templates/invoices/v4/_one_off.slim
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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.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
- 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_due')
td.body-1 = MoneyHelper.format(total_amount)
Loading

0 comments on commit bda45af

Please sign in to comment.