Commit 60a0cfe
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
- spec/graphql/types/customers
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
| 9 | + | |
| 10 | + | |
9 | 11 | | |
10 | 12 | | |
11 | 13 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
85 | 85 | | |
86 | 86 | | |
87 | 87 | | |
88 | | - | |
| 88 | + | |
89 | 89 | | |
90 | 90 | | |
91 | 91 | | |
| |||
166 | 166 | | |
167 | 167 | | |
168 | 168 | | |
169 | | - | |
170 | | - | |
171 | | - | |
172 | | - | |
173 | | - | |
174 | | - | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
175 | 179 | | |
176 | 180 | | |
177 | 181 | | |
| |||
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
93 | 226 | | |
0 commit comments