Skip to content

Commit 60a0cfe

Browse files
authored
feat(customers): group credit notes by entity (#5565)
## Context The customer detail page is replacing its single "Total amount available" credit-notes summary card with a per-(currency × billing entity) breakdown table, mirroring the Invoice balances treatment that's landing alongside it. Today the `customer.creditNotesBalances` GraphQL field only groups by currency, so customers transacting across multiple billing entities cannot see their balances split by entity, and there's no per-bucket count of credit notes for the row subtitle. ## Description `CustomerCreditNotesBalance` now exposes two new non-null fields (`billingEntityId: ID!` and `creditsAvailableCount: Int!`) in addition to the existing `currency` and `amountCents`. The `customer.creditNotesBalances` resolver groups finalized credit notes by both currency and billing entity (joining `invoices`) and emits one row per `(currency, billingEntity)` pair, returning the sum of `balance_amount_cents` and the count of finalized credit notes with `credit_amount_cents > 0`. The previous `balance_amount_cents > 0` filter is removed so buckets with fully-consumed credits still flow through: this is required so the consumer can render the "credit notes issued & credited" subtitle even when the available balance is zero. The consumer applies visibility client-side via `amountCents > 0 OR creditsAvailableCount > 0`. Old vs new response shape for the same customer (one billing entity, single currency, and one cross-entity case): | Customer | Before | After | | --- | --- | --- | | Single-entity, single-currency | `[{ currency: "EUR", amountCents: 1500 }]` | `[{ currency: "EUR", billingEntityId: "...", amountCents: 1500, creditsAvailableCount: 3 }]` | | Cross-currency, cross-entity (3 buckets) | 1 row per currency only | 1 row per `(currency, billingEntity)` pair | | Fully-consumed bucket | omitted | included with `amountCents: 0` and `creditsAvailableCount > 0` | Implementation notes: - Uses PostgreSQL's `COUNT(*) FILTER (WHERE ...)` rather than `COUNT(CASE WHEN ...)`. Lago is PG-only; the FILTER form is equivalent and more idiomatic. - Raw SQL aggregates are wrapped in `Arel.sql(...)` to satisfy Rails 8's `disable_joins`-style protection against unsafe `pluck` inputs. - `joins(:invoice)` is safe: `credit_notes.invoice_id` is `NOT NULL` with a FK, and `invoices.billing_entity_id` is `NOT NULL` at the DB level, so no rows are dropped and the new `ID!` typing is sound. - `schema.graphql` and `schema.json` are regenerated. ## How to try locally 1. Check out the branch and rebuild the API container if needed. 2. Seed or open a customer with credit notes across multiple currencies and billing entities (a quick `rails console` script that creates a couple of `:billing_entity`, a couple of `:invoice`, and several `:credit_note` records works). 3. Run the GraphQL query against the customer: ```graphql query($id: ID!) { customer(id: $id) { creditNotesBalances { currency billingEntityId amountCents creditsAvailableCount } } } ``` Confirm: one row per `(currency, billingEntityId)` pair, amounts sum correctly per bucket, and counts only include credit notes with `credit_amount_cents > 0`. 4. Verify a single-entity, single-currency customer still returns exactly one row (no regression). 5. `bundle exec rspec spec/graphql/types/customers/object_spec.rb spec/graphql/lago_api_schema_spec.rb` to confirm the new behavior specs and the schema-dump cross-check both pass.
1 parent cc1fc08 commit 60a0cfe

5 files changed

Lines changed: 183 additions & 10 deletions

File tree

app/graphql/types/customers/credit_notes_balance.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ class CreditNotesBalance < Types::BaseObject
66
graphql_name "CustomerCreditNotesBalance"
77

88
field :amount_cents, GraphQL::Types::BigInt, null: false
9+
field :billing_entity_id, ID, null: false
10+
field :credits_available_count, Integer, null: false
911
field :currency, Types::CurrencyEnum, null: false
1012
end
1113
end

app/graphql/types/customers/object.rb

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ class Object < Types::BaseObject
8585
field :credit_notes_balances,
8686
[Types::Customers::CreditNotesBalance],
8787
null: false,
88-
description: "Credit notes credits balance available per customer per currency"
88+
description: "Credit notes balances broken down by (currency, billing entity)"
8989
field :credit_notes_credits_available_count,
9090
Integer,
9191
null: false,
@@ -166,12 +166,16 @@ def credit_notes_balance_amount_cents
166166
end
167167

168168
def credit_notes_balances
169-
object.credit_notes
170-
.finalized
171-
.where("credit_notes.balance_amount_cents > 0")
172-
.group("credit_notes.total_amount_currency")
173-
.sum("credit_notes.balance_amount_cents")
174-
.map { |currency, amount_cents| {currency:, amount_cents:} }
169+
object.credit_notes.finalized.joins(:invoice)
170+
.group("credit_notes.total_amount_currency", "invoices.billing_entity_id")
171+
.pluck(
172+
"credit_notes.total_amount_currency",
173+
"invoices.billing_entity_id",
174+
Arel.sql("SUM(credit_notes.balance_amount_cents)"),
175+
Arel.sql("COUNT(*) FILTER (WHERE credit_notes.credit_amount_cents > 0)")
176+
).map { |currency, billing_entity_id, amount_cents, credits_available_count|
177+
{currency:, billing_entity_id:, amount_cents:, credits_available_count:}
178+
}
175179
end
176180

177181
def billing_configuration

schema.graphql

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

schema.json

Lines changed: 34 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

spec/graphql/types/customers/object_spec.rb

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,137 @@
9090

9191
expect(subject).to have_field(:error_details).of_type("[ErrorDetail!]")
9292
end
93+
94+
describe "credit_notes_balances grouping" do
95+
let(:required_permission) { "customers:view" }
96+
let(:membership) { create(:membership) }
97+
let(:organization) { membership.organization }
98+
let(:customer) { create(:customer, organization:) }
99+
let(:billing_entity_a) { create(:billing_entity, organization:) }
100+
let(:billing_entity_b) { create(:billing_entity, organization:) }
101+
102+
let(:query) do
103+
<<~GQL
104+
query($customerId: ID!) {
105+
customer(id: $customerId) {
106+
creditNotesBalances {
107+
currency
108+
billingEntityId
109+
amountCents
110+
creditsAvailableCount
111+
}
112+
}
113+
}
114+
GQL
115+
end
116+
117+
def execute
118+
execute_graphql(
119+
current_user: membership.user,
120+
current_organization: organization,
121+
permissions: required_permission,
122+
query:,
123+
variables: {customerId: customer.id}
124+
).dig("data", "customer", "creditNotesBalances")
125+
end
126+
127+
context "with a single (currency, billing entity) bucket" do
128+
before do
129+
invoice = create(:invoice, customer:, organization:, billing_entity: billing_entity_a)
130+
create(:credit_note, customer:, organization:, invoice:,
131+
total_amount_currency: "EUR", balance_amount_cents: 100, credit_amount_cents: 100)
132+
create(:credit_note, customer:, organization:, invoice:,
133+
total_amount_currency: "EUR", balance_amount_cents: 50, credit_amount_cents: 50)
134+
end
135+
136+
it "returns one row aggregating both credit notes" do
137+
expect(execute).to match_array([
138+
{
139+
"currency" => "EUR",
140+
"billingEntityId" => billing_entity_a.id,
141+
"amountCents" => "150",
142+
"creditsAvailableCount" => 2
143+
}
144+
])
145+
end
146+
end
147+
148+
context "with multiple currencies under one billing entity" do
149+
before do
150+
invoice_eur = create(:invoice, customer:, organization:, billing_entity: billing_entity_a)
151+
invoice_usd = create(:invoice, customer:, organization:, billing_entity: billing_entity_a)
152+
create(:credit_note, customer:, organization:, invoice: invoice_eur,
153+
total_amount_currency: "EUR", balance_amount_cents: 100, credit_amount_cents: 100)
154+
create(:credit_note, customer:, organization:, invoice: invoice_usd,
155+
total_amount_currency: "USD", balance_amount_cents: 200, credit_amount_cents: 200)
156+
end
157+
158+
it "returns one row per currency for the same billing entity" do
159+
expect(execute).to match_array([
160+
{"currency" => "EUR", "billingEntityId" => billing_entity_a.id, "amountCents" => "100", "creditsAvailableCount" => 1},
161+
{"currency" => "USD", "billingEntityId" => billing_entity_a.id, "amountCents" => "200", "creditsAvailableCount" => 1}
162+
])
163+
end
164+
end
165+
166+
context "with multiple billing entities under one currency" do
167+
before do
168+
invoice_a = create(:invoice, customer:, organization:, billing_entity: billing_entity_a)
169+
invoice_b = create(:invoice, customer:, organization:, billing_entity: billing_entity_b)
170+
create(:credit_note, customer:, organization:, invoice: invoice_a,
171+
total_amount_currency: "EUR", balance_amount_cents: 100, credit_amount_cents: 100)
172+
create(:credit_note, customer:, organization:, invoice: invoice_b,
173+
total_amount_currency: "EUR", balance_amount_cents: 400, credit_amount_cents: 400)
174+
end
175+
176+
it "returns one row per billing entity for the same currency" do
177+
expect(execute).to match_array([
178+
{"currency" => "EUR", "billingEntityId" => billing_entity_a.id, "amountCents" => "100", "creditsAvailableCount" => 1},
179+
{"currency" => "EUR", "billingEntityId" => billing_entity_b.id, "amountCents" => "400", "creditsAvailableCount" => 1}
180+
])
181+
end
182+
end
183+
184+
context "with multiple currencies and billing entities combined" do
185+
before do
186+
invoice_eur_a = create(:invoice, customer:, organization:, billing_entity: billing_entity_a)
187+
invoice_usd_a = create(:invoice, customer:, organization:, billing_entity: billing_entity_a)
188+
invoice_eur_b = create(:invoice, customer:, organization:, billing_entity: billing_entity_b)
189+
create(:credit_note, customer:, organization:, invoice: invoice_eur_a,
190+
total_amount_currency: "EUR", balance_amount_cents: 100, credit_amount_cents: 100)
191+
create(:credit_note, customer:, organization:, invoice: invoice_usd_a,
192+
total_amount_currency: "USD", balance_amount_cents: 200, credit_amount_cents: 200)
193+
create(:credit_note, customer:, organization:, invoice: invoice_eur_b,
194+
total_amount_currency: "EUR", balance_amount_cents: 400, credit_amount_cents: 400)
195+
end
196+
197+
it "returns one row per (currency, billing entity) pair" do
198+
expect(execute).to match_array([
199+
{"currency" => "EUR", "billingEntityId" => billing_entity_a.id, "amountCents" => "100", "creditsAvailableCount" => 1},
200+
{"currency" => "USD", "billingEntityId" => billing_entity_a.id, "amountCents" => "200", "creditsAvailableCount" => 1},
201+
{"currency" => "EUR", "billingEntityId" => billing_entity_b.id, "amountCents" => "400", "creditsAvailableCount" => 1}
202+
])
203+
end
204+
end
205+
206+
context "with a fully-consumed credit (zero balance, positive credit amount)" do
207+
before do
208+
invoice = create(:invoice, customer:, organization:, billing_entity: billing_entity_a)
209+
create(:credit_note, customer:, organization:, invoice:,
210+
total_amount_currency: "EUR", balance_amount_cents: 0, credit_amount_cents: 500)
211+
end
212+
213+
it "still returns the bucket so the FE can render the 'credited' subtitle" do
214+
expect(execute).to match_array([
215+
{"currency" => "EUR", "billingEntityId" => billing_entity_a.id, "amountCents" => "0", "creditsAvailableCount" => 1}
216+
])
217+
end
218+
end
219+
220+
context "when the customer has no finalized credit notes" do
221+
it "returns an empty array" do
222+
expect(execute).to eq([])
223+
end
224+
end
225+
end
93226
end

0 commit comments

Comments
 (0)