Skip to content

feat(backend): implement invoice settlement mode for credits#3976

Open
mark-vass-konghq wants to merge 1 commit intomainfrom
feat/invoice-settlement-mode-for-credits
Open

feat(backend): implement invoice settlement mode for credits#3976
mark-vass-konghq wants to merge 1 commit intomainfrom
feat/invoice-settlement-mode-for-credits

Conversation

@mark-vass-konghq
Copy link
Contributor

@mark-vass-konghq mark-vass-konghq commented Mar 19, 2026

First implementation for invoice settlement mode for credits

Summary by CodeRabbit

  • New Features

    • Added invoice-settlement support for credit-purchase charges: lifecycle to link, authorize, and settle invoiced payments; automatic post-create invoice linking; invoice event processing now drives authorization/settlement workflows.
    • Invoice creation now preserves a line's description on the created invoice; invoice update accepts an optional description.
  • Bug Fixes

    • Added validation and idempotency to invoice settlement paths to prevent duplicate linkage and ensure correct transitions.
  • Tests

    • Re-enabled and extended invoice credit-purchase tests with stronger assertions and cross-step verification.

@mark-vass-konghq mark-vass-konghq self-assigned this Mar 19, 2026
@mark-vass-konghq mark-vass-konghq requested a review from a team as a code owner March 19, 2026 19:27
@mark-vass-konghq mark-vass-konghq added the release-note/feature Release note: Exciting New Features label Mar 19, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 19, 2026

📝 Walkthrough

Walkthrough

Adds invoiced-payment support for credit-purchase charges: adapter CRUD for invoiced payments, DB→domain mapping of InvoiceSettlement, service lifecycle for linking/authorizing/settling invoiced payments, and deferred invoicing integration into charge creation with post-transaction linking. (50 words)

Changes

Cohort / File(s) Summary
Interfaces
openmeter/billing/charges/creditpurchase/adapter.go, openmeter/billing/charges/creditpurchase/service.go
Adapter interface gains CreateInvoicedPayment and UpdateInvoicedPayment; Service now embeds InvoicePaymentLifecycle with Link/Authorized/Settled handlers.
Adapter — charge & mapper
openmeter/billing/charges/creditpurchase/adapter/charge.go, openmeter/billing/charges/creditpurchase/adapter/mapper.go
Persisted charge mapping now loads and preserves InvoiceSettlement; mapper reads InvoicedPayment edge when meta.ExpandRealizations is requested and surfaces it in Charge.State.
Adapter — payment CRUD
openmeter/billing/charges/creditpurchase/adapter/payment.go
Implements CreateInvoicedPayment and UpdateInvoicedPayment with validation and transactional Ent operations; imports chargecreditpurchaseinvoicedpayment Ent model.
Domain validation
openmeter/billing/charges/creditpurchase/chargecreditpurchase.go
Introduces validator helper and validateOptionalValidator to centralize typed-nil checks; adds validation for State.InvoiceSettlement.
Service — credit-purchase invoice flow
openmeter/billing/charges/creditpurchase/service/invoice.go, openmeter/billing/charges/creditpurchase/service/create.go
New invoice flow: on-invoice-settlement creation, LinkInvoicedPayment, HandleInvoicePaymentAuthorized, HandleInvoicePaymentSettled; creates/updates invoiced payments, updates charge state, invokes handler callbacks.
Charge service integration
openmeter/billing/charges/service/create.go, openmeter/billing/charges/service/invoice.go, openmeter/billing/charges/service/invoicependinglines.go
Defers invoice-settlement credit purchases into post-transaction pending-invoice workflow, creates pending gathering lines, invoices due lines, links invoiced payments; invoice event processors now route credit-purchase charges to lifecycle handlers; allocation logic accepts credit-purchase lines.
Invoice adapter / types
openmeter/billing/adapter/invoiceapp.go, openmeter/billing/stdinvoice.go
UpdateInvoiceFields supports optional Description update; UpdateInvoiceFieldsInput gains exported Description *string.
Tests
openmeter/billing/charges/service/creditpurchase_test.go
Re-enabled and expanded TestStandardInvoiceCreditPurchase assertions, added cross-phase invoice fetches and debugging to validate authorized/settled flows.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant ChargeService
    participant BillingService
    participant CreditPurchaseService
    participant Adapter
    participant Handler
    participant DB

    Client->>ChargeService: Create CreditPurchase (Settlement=Invoice)
    activate ChargeService
    ChargeService->>CreditPurchaseService: onInvoiceSettlementPurchase
    activate CreditPurchaseService
    CreditPurchaseService->>Handler: OnCreditPurchaseInitiated
    Handler-->>CreditPurchaseService: TimedGroupReference
    CreditPurchaseService->>Adapter: UpdateCharge (persist realization)
    Adapter->>DB: persist charge
    deactivate CreditPurchaseService

    ChargeService->>BillingService: Create pending invoice lines
    BillingService->>DB: create pending invoice & lines
    BillingService-->>ChargeService: pending line(s)
    ChargeService->>BillingService: InvoicePendingLines (if due)
    BillingService->>DB: finalize invoice
    BillingService-->>ChargeService: invoiceID

    ChargeService->>CreditPurchaseService: LinkInvoicedPayment(invoiceID, lineID)
    activate CreditPurchaseService
    CreditPurchaseService->>Adapter: CreateInvoicedPayment
    Adapter->>DB: create invoiced payment
    Adapter-->>CreditPurchaseService: invoiced payment
    CreditPurchaseService->>Adapter: UpdateCharge (set InvoiceSettlement)
    Adapter->>DB: persist charge
    deactivate CreditPurchaseService
    deactivate ChargeService

    Client->>BillingService: Payment trigger (Authorized)
    BillingService->>CreditPurchaseService: HandleInvoicePaymentAuthorized(invoiceID,line)
    activate CreditPurchaseService
    CreditPurchaseService->>Handler: OnCreditPurchasePaymentAuthorized
    CreditPurchaseService->>Adapter: UpdateInvoicedPayment (record authorized TR)
    Adapter->>DB: update payment
    CreditPurchaseService->>Adapter: UpdateCharge
    Adapter->>DB: persist charge
    deactivate CreditPurchaseService

    Client->>BillingService: Payment trigger (Paid)
    BillingService->>CreditPurchaseService: HandleInvoicePaymentSettled(invoiceID,line)
    activate CreditPurchaseService
    CreditPurchaseService->>Handler: OnCreditPurchasePaymentSettled
    CreditPurchaseService->>Adapter: UpdateInvoicedPayment (record settled TR & status)
    Adapter->>DB: update payment
    CreditPurchaseService->>Adapter: UpdateCharge (finalize)
    Adapter->>DB: persist charge
    deactivate CreditPurchaseService
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • PR #3973: Adds invoice-settlement fields and invoiced-payment schema changes that this PR consumes.
  • PR #3961: Implements similar invoiced-payment support and mappings for credit-purchase charges.
  • PR #3894: Overlaps on charge-mapping and adapter payment method changes related to credit-purchase invoiced payments.

Suggested labels

area/billing

Suggested reviewers

  • turip
  • tothandras
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: implementing invoice settlement mode for credit purchases across the codebase.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/invoice-settlement-mode-for-credits
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can approve the review once all CodeRabbit's comments are resolved.

Enable the reviews.request_changes_workflow setting to automatically approve the review once all CodeRabbit's comments are resolved.

@mark-vass-konghq
Copy link
Contributor Author

@CodeRabbit review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 19, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (1)
openmeter/billing/charges/service/creditpurchase_test.go (1)

363-565: Could we add a case for the deferred-invoicing branch too?

This test covers the happy path where the gathering line is turned into a standard invoice during Create(). The new flow in handleInvoiceCreditPurchasePostCreate() also has a createdLine.InvoiceAt.After(clock.Now()) branch, and that’s the lifecycle edge most likely to regress because InvoiceSettlement stays unset until later.

As per coding guidelines **/*_test.go: Make sure the tests are comprehensive and cover the changes. Keep a strong focus on unit tests and in-code integration tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/billing/charges/service/creditpurchase_test.go` around lines 363 -
565, Add a new sub-test to TestStandardInvoiceCreditPurchase that exercises the
deferred-invoicing branch (createdLine.InvoiceAt.After(clock.Now())) in
handleInvoiceCreditPurchasePostCreate: create a credit purchase intent whose
invoice date is in the future (so InvoiceSettlement remains nil at Create()),
assert in the "initiated" callback that charge.State.InvoiceSettlement is nil,
then simulate the passage to invoice creation (invoke whatever path
creates/archives the invoice or call the same CustomInvoicingService trigger
used later) and finally assert that after the invoice is created and approved
the charge's State.InvoiceSettlement is set and populated (check InvoiceID,
Authorized/Settled group IDs and final charge status). Reference
TestStandardInvoiceCreditPurchase, handleInvoiceCreditPurchasePostCreate,
createdLine.InvoiceAt.After(clock.Now()), and InvoiceSettlement to locate where
to add and what to assert.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openmeter/billing/charges/creditpurchase/chargecreditpurchase.go`:
- Around line 99-123: The validateOptionalValidator function currently checks v
== nil but fails to handle typed-nil values stored in the validator interface
(causing panics when calling v.Validate()); update validateOptionalValidator to
detect an underlying nil pointer before calling Validate by either using a type
switch on validator to check each pointer concrete type for nil or by using
reflection (reflect.ValueOf(v).Kind()==Ptr && reflect.ValueOf(v).IsNil()) to
return nil early; ensure you still call v.Validate() only when the underlying
value is non-nil so functions like validateOptionalValidator,
validator.Validate, and callers (e.g., CreditGrantRealization,
ExternalPaymentSettlement, InvoiceSettlement) no longer panic.

In `@openmeter/billing/charges/creditpurchase/service/invoice.go`:
- Around line 49-61: Currently LinkInvoicedPayment seeds the
newPaymentSettlement (payment.InvoicedCreate) with Authorized and
StatusAuthorized using charge.State.CreditGrantRealization which makes the
settlement appear already authorized; change this to create the InvoicedCreate
without setting Authorized and leave Status as a pre-authorization state (e.g.,
payment.StatusPending or nil), and move the assignment of Authorized and the
transition to payment.StatusAuthorized into HandleInvoicePaymentAuthorized where
the actual authorization is handled, updating the same fields (Authorized and
Status) on the existing settlement record.

In `@openmeter/billing/charges/service/create.go`:
- Around line 261-297: The code only links invoiced payments when
createdLine.InvoiceAt is past/now; for future-dated lines you must record the
pending linkage so later invoice callbacks can complete it. When
createdLine.InvoiceAt is in the future, set the charge's invoice-settlement
information (e.g., populate charge.State.InvoiceSettlement or the equivalent
field with the pending line ID createdLine.ID and any needed invoice/customer
info) and persist the charge immediately using the same persistence path used
elsewhere in this flow so that later handlers can call
s.creditPurchaseService.LinkInvoicedPayment(charge, ...) and finish linking;
ensure the persistence uses the existing charge save/update method used in this
service so state is durable.
- Around line 160-169: The loop calling handleInvoiceCreditPurchasePostCreate()
can surface a hard error after the charge is already committed; change the flow
so Create() does not return a hard error post-commit: mark pending
invoice-credit purchases durable (e.g., set a processed/pending flag tied to the
charge ID) inside the main transaction, then perform
handleInvoiceCreditPurchasePostCreate() as a best-effort background/async step
(or catch errors, log them, and attach a non-fatal warning/state to the returned
charges) instead of returning fmt.Errorf; also make
handleInvoiceCreditPurchasePostCreate() idempotent by deduping on the
charge/transaction ID so retries/background processing cannot double-apply
credits (references: Create, result.pendingInvoiceCreditPurchases,
handleInvoiceCreditPurchasePostCreate, out, charges.NewCharge).
- Around line 23-26: The file fails the repository's Go formatting/import
ordering (gci) check for the createResult declaration; run gofmt and the
project's import sorter (gci or goimports as configured) on the file containing
the createResult struct so the struct definition (type createResult and its
fields charges.Charges and pendingInvoiceCreditPurchases of
charges.WithIndex[*creditPurchaseWithPendingGatheringLine]) is formatted and
imports reordered to match repo style, then stage the resulting changes.

In `@openmeter/billing/charges/service/invoice.go`:
- Around line 33-47: The credit-purchase authorization can be executed twice
because this file routes credit purchases to
creditPurchaseService.HandleInvoicePaymentAuthorized on
StandardInvoiceStatusPaymentProcessingPending while the credit-purchase service
already links the settlement in payment.StatusAuthorized; to fix, either stop
routing here (make the creditPurchase entry a no-op like in the Issued branch)
or make the called handler idempotent by short-circuiting when the settlement is
already authorized — implement the chosen fix by updating the processorByType
mapping in handleChargeEvent (the creditPurchase function assigned here) or by
adding an early return in creditpurchase.Service.HandleInvoicePaymentAuthorized
that checks the settlement/payment status and returns nil if already authorized.

---

Nitpick comments:
In `@openmeter/billing/charges/service/creditpurchase_test.go`:
- Around line 363-565: Add a new sub-test to TestStandardInvoiceCreditPurchase
that exercises the deferred-invoicing branch
(createdLine.InvoiceAt.After(clock.Now())) in
handleInvoiceCreditPurchasePostCreate: create a credit purchase intent whose
invoice date is in the future (so InvoiceSettlement remains nil at Create()),
assert in the "initiated" callback that charge.State.InvoiceSettlement is nil,
then simulate the passage to invoice creation (invoke whatever path
creates/archives the invoice or call the same CustomInvoicingService trigger
used later) and finally assert that after the invoice is created and approved
the charge's State.InvoiceSettlement is set and populated (check InvoiceID,
Authorized/Settled group IDs and final charge status). Reference
TestStandardInvoiceCreditPurchase, handleInvoiceCreditPurchasePostCreate,
createdLine.InvoiceAt.After(clock.Now()), and InvoiceSettlement to locate where
to add and what to assert.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cd5a7b49-62cf-4812-a0e6-f047b09bd57a

📥 Commits

Reviewing files that changed from the base of the PR and between 4971c89 and b7ed0eb.

📒 Files selected for processing (12)
  • openmeter/billing/charges/creditpurchase/adapter.go
  • openmeter/billing/charges/creditpurchase/adapter/charge.go
  • openmeter/billing/charges/creditpurchase/adapter/mapper.go
  • openmeter/billing/charges/creditpurchase/adapter/payment.go
  • openmeter/billing/charges/creditpurchase/chargecreditpurchase.go
  • openmeter/billing/charges/creditpurchase/service.go
  • openmeter/billing/charges/creditpurchase/service/create.go
  • openmeter/billing/charges/creditpurchase/service/invoice.go
  • openmeter/billing/charges/service/create.go
  • openmeter/billing/charges/service/creditpurchase_test.go
  • openmeter/billing/charges/service/invoice.go
  • openmeter/billing/charges/service/invoicependinglines.go

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
openmeter/billing/charges/creditpurchase/chargecreditpurchase.go (1)

118-131: Nice fix for the typed-nil interface issue!

This correctly handles the tricky Go semantics where a nil pointer wrapped in an interface isn't == nil. Small optimization: you're calling reflect.ValueOf(v) twice.

♻️ Optional: cache the reflect.Value
 func validateOptionalValidator(v validator, name string) error {
 	// Handle typed-nil values: when a nil pointer is stored in an interface,
 	// the interface itself is not nil, but the underlying value is.
 	if v == nil {
 		return nil
 	}
-	if reflect.ValueOf(v).Kind() == reflect.Ptr && reflect.ValueOf(v).IsNil() {
+	rv := reflect.ValueOf(v)
+	if rv.Kind() == reflect.Ptr && rv.IsNil() {
 		return nil
 	}
 	if err := v.Validate(); err != nil {
 		return fmt.Errorf("%s: %w", name, err)
 	}
 	return nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/billing/charges/creditpurchase/chargecreditpurchase.go` around
lines 118 - 131, In validateOptionalValidator, avoid calling reflect.ValueOf(v)
twice by assigning rv := reflect.ValueOf(v) once and using rv.Kind() and
rv.IsNil() for the nil-pointer checks; keep the same behavior and error wrapping
using v.Validate() and fmt.Errorf with the provided name.
openmeter/billing/charges/creditpurchase/service/invoice.go (1)

86-93: Idempotency check is clever but warrants a comment clarification.

The logic is: if InvoiceSettlement.Authorized exists AND has a different transaction group ID than CreditGrantRealization, then this handler already ran (since LinkInvoicedPayment copies the same ID, but this handler generates a new one). This is subtle - consider adding a brief inline explanation for future readers.

📝 Clearer comment
 	// Idempotency check: if already authorized via invoice approval (not just initial credit grant), skip processing.
-	// LinkInvoicedPayment sets Authorized to CreditGrantRealization, so we check if it's been updated
-	// by this handler (which would have a different transaction group ID).
+	// LinkInvoicedPayment initially copies CreditGrantRealization's TransactionGroupID into Authorized.
+	// This handler generates a NEW TransactionGroupID when processing. So if Authorized.TransactionGroupID
+	// differs from CreditGrantRealization's, this handler already ran and we skip to avoid double-processing.
 	if charge.State.InvoiceSettlement.Authorized != nil &&
 		charge.State.CreditGrantRealization != nil &&
 		charge.State.InvoiceSettlement.Authorized.TransactionGroupID != charge.State.CreditGrantRealization.GroupReference.TransactionGroupID {
 		return nil
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/billing/charges/creditpurchase/service/invoice.go` around lines 86
- 93, Clarify the idempotency check in invoice.go by adding a concise inline
comment next to the conditional that explains the logic: state when
InvoiceSettlement.Authorized exists and its TransactionGroupID differs from
CreditGrantRealization.GroupReference.TransactionGroupID means this handler
already ran (because LinkInvoicedPayment copies the original group ID while this
handler generates a new one), so we should return; reference the symbols
InvoiceSettlement.Authorized, CreditGrantRealization, LinkInvoicedPayment, and
TransactionGroupID in the comment to make the intent obvious to future readers.
openmeter/billing/charges/service/create.go (2)

160-174: Silent error swallowing is intentional but could use observability.

The comment explains the rationale well - charges are already committed, and this is best-effort. However, silently dropping errors makes debugging tricky in production. Consider logging these failures so operators can spot patterns or manually intervene.

💡 Add structured logging for visibility
 lo.ForEach(result.pendingInvoiceCreditPurchases, func(pending charges.WithIndex[*creditPurchaseWithPendingGatheringLine], _ int) {
 	updatedCharge, err := s.handleInvoiceCreditPurchasePostCreate(ctx, pending.Value)
 	if err != nil {
 		// Skip this charge on error - it has already been committed and can be
 		// processed later. The charge will not have InvoiceSettlement set.
+		s.logger.Warn("post-create handling failed for invoice credit purchase",
+			"charge_id", pending.Value.charge.ID,
+			"customer_id", pending.Value.customerID.ID,
+			"error", err,
+		)
 		return
 	}

 	out[pending.Index] = charges.NewCharge(updatedCharge)
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/billing/charges/service/create.go` around lines 160 - 174, The
post-create loop over result.pendingInvoiceCreditPurchases currently swallows
errors; update it to log structured error details when
s.handleInvoiceCreditPurchasePostCreate(ctx, pending.Value) returns an error
while preserving the current behavior of skipping the charge. Specifically, add
a processLogger (or existing logger) call that includes the charge id or
pending.Value identifier, pending.Index, and the error returned from
handleInvoiceCreditPurchasePostCreate so operators can monitor failures, but
still return/continue without altering out[pending.Index] on error.

259-274: Acknowledged: duplicate gathering lines on retry are a known gap.

The comment at lines 260-262 clearly documents this limitation. For future hardening, you could query by ChargeID before creating, or persist a partial InvoiceSettlement with the gathering line ID immediately after creation. Not blocking for this PR since it's documented, but worth tracking.

Want me to open an issue to track implementing deduplication for gathering line creation on retries?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/billing/charges/service/create.go` around lines 259 - 274, The
current code calling s.billingService.CreatePendingInvoiceLines with
pending.gatheringLine can create duplicate gathering lines on retries; before
creating, call a billing-side lookup (e.g., implement and use a method like
billingService.FindGatheringLinesByChargeID or ListPendingInvoiceLines filtered
by pending.chargeID) and if a matching gathering line already exists skip
CreatePendingInvoiceLines, otherwise call CreatePendingInvoiceLines and
immediately persist a partial InvoiceSettlement containing the new gathering
line ID (or record the created line ID on the pending object) so retries detect
the existing line and no duplicate is created; update the logic around
CreatePendingInvoiceLines and ensure checks use pending.chargeID and
pending.gatheringLine identifiers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openmeter/billing/charges/creditpurchase/service/invoice.go`:
- Line 15: The function name has a typo: rename onInvoceSettlementPurchase to
onInvoiceSettlementPurchase and update all references/call sites and any tests
or interface implementations that use onInvoceSettlementPurchase to the
corrected name; ensure any exported/internal usages, documentation, and mocks
reflect the new identifier so builds and references remain consistent.

In `@openmeter/billing/charges/service/create.go`:
- Around line 290-292: The check that only inspects invoices[0] is brittle;
iterate all returned invoices from InvoicePendingLines (the invoices slice) and
search each invoice's Lines.OrEmpty() for the matching ChargeID rather than
assuming invoices[0] contains the standard line; if no matching line is found
across all invoices return the existing error ("no standard invoice created for
credit purchase" or "standard line not found") so downstream logic (the later
loop that uses ChargeID) will always have the correct line regardless of which
invoice it landed on.

---

Nitpick comments:
In `@openmeter/billing/charges/creditpurchase/chargecreditpurchase.go`:
- Around line 118-131: In validateOptionalValidator, avoid calling
reflect.ValueOf(v) twice by assigning rv := reflect.ValueOf(v) once and using
rv.Kind() and rv.IsNil() for the nil-pointer checks; keep the same behavior and
error wrapping using v.Validate() and fmt.Errorf with the provided name.

In `@openmeter/billing/charges/creditpurchase/service/invoice.go`:
- Around line 86-93: Clarify the idempotency check in invoice.go by adding a
concise inline comment next to the conditional that explains the logic: state
when InvoiceSettlement.Authorized exists and its TransactionGroupID differs from
CreditGrantRealization.GroupReference.TransactionGroupID means this handler
already ran (because LinkInvoicedPayment copies the original group ID while this
handler generates a new one), so we should return; reference the symbols
InvoiceSettlement.Authorized, CreditGrantRealization, LinkInvoicedPayment, and
TransactionGroupID in the comment to make the intent obvious to future readers.

In `@openmeter/billing/charges/service/create.go`:
- Around line 160-174: The post-create loop over
result.pendingInvoiceCreditPurchases currently swallows errors; update it to log
structured error details when s.handleInvoiceCreditPurchasePostCreate(ctx,
pending.Value) returns an error while preserving the current behavior of
skipping the charge. Specifically, add a processLogger (or existing logger) call
that includes the charge id or pending.Value identifier, pending.Index, and the
error returned from handleInvoiceCreditPurchasePostCreate so operators can
monitor failures, but still return/continue without altering out[pending.Index]
on error.
- Around line 259-274: The current code calling
s.billingService.CreatePendingInvoiceLines with pending.gatheringLine can create
duplicate gathering lines on retries; before creating, call a billing-side
lookup (e.g., implement and use a method like
billingService.FindGatheringLinesByChargeID or ListPendingInvoiceLines filtered
by pending.chargeID) and if a matching gathering line already exists skip
CreatePendingInvoiceLines, otherwise call CreatePendingInvoiceLines and
immediately persist a partial InvoiceSettlement containing the new gathering
line ID (or record the created line ID on the pending object) so retries detect
the existing line and no duplicate is created; update the logic around
CreatePendingInvoiceLines and ensure checks use pending.chargeID and
pending.gatheringLine identifiers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 13a8615a-b91a-421a-8c80-8bd7d135049b

📥 Commits

Reviewing files that changed from the base of the PR and between b7ed0eb and 4218be2.

📒 Files selected for processing (3)
  • openmeter/billing/charges/creditpurchase/chargecreditpurchase.go
  • openmeter/billing/charges/creditpurchase/service/invoice.go
  • openmeter/billing/charges/service/create.go

@mark-vass-konghq mark-vass-konghq force-pushed the feat/invoice-settlement-mode-for-credits branch from 4218be2 to 33bf9dc Compare March 20, 2026 12:51
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
openmeter/billing/charges/service/creditpurchase_test.go (1)

380-397: ⚠️ Potential issue | 🟠 Major

Please add coverage for the deferred invoice path too.

With ServicePeriod.From fixed to January 1, 2026, this test only exercises the immediate InvoiceAt <= now branch. The new deferred path—pending line created first, InvoiceSettlement linked later by invoice callbacks—is still uncovered, and that’s the branch most likely to regress here. A companion integration test with a future service period would make this change a lot safer.

As per coding guidelines, "Make sure the tests are comprehensive and cover the changes. Keep a strong focus on unit tests and in-code integration tests. When appropriate, recommend e2e tests for critical changes."

Also applies to: 403-565

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/billing/charges/service/creditpurchase_test.go` around lines 380 -
397, The test only exercises the immediate invoice path because
ServicePeriod.From is set to 2026-01-01 (making InvoiceAt <= now); add a
companion test variant that sets the service period
(createCreditPurchaseIntentInput.ServicePeriod.From) to a future date so the
flow creates a pending line first and later links an InvoiceSettlement via the
invoice callback path (exercise
creditpurchase.NewSettlement/creditpurchase.InvoiceSettlement, InvoiceAt branch,
and the pending-to-invoiced transition). In that new test, call
CreateCreditPurchaseIntent with a future ServicePeriod.From, assert that the
initial state is a pending line (no invoice created), then simulate the invoice
callback that attaches the InvoiceSettlement and assert the invoice is
created/linked and balances update accordingly; reuse existing helpers around
CreateCreditPurchaseIntent and assertions to mirror the immediate-path test.
♻️ Duplicate comments (1)
openmeter/billing/charges/service/create.go (1)

245-267: ⚠️ Potential issue | 🟠 Major

The future-dated branch still isn’t safely retryable.

On the InvoiceAt > now path, InvoiceSettlement stays nil, so this guard never flips on a replay. Any retry/background rerun of this helper can call CreatePendingInvoiceLines() again for the same charge and materialize duplicate pending invoice lines. This needs a durable “line already created” marker, or a lookup by ChargeID before creating another line.

Also applies to: 316-320

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/billing/charges/service/create.go` around lines 245 - 267,
handleInvoiceCreditPurchasePostCreate can create duplicate pending invoice lines
for future-dated charges because InvoiceSettlement stays nil; ensure idempotency
by checking for an existing gathering line tied to the charge before calling
billingService.CreatePendingInvoiceLines or by persisting a partial
InvoiceSettlement (including the created gathering line ID) immediately after
creating the gathering line. Concretely: in
handleInvoiceCreditPurchasePostCreate, before calling CreatePendingInvoiceLines,
query the billing side for existing gathering lines filtered by ChargeID (or use
an existing lookup method) and return early if one exists; if you must create a
line, write back a durable marker (e.g., set Charge.State.InvoiceSettlement with
gatheringLine ID or persist a minimal settlement record) so subsequent retries
see InvoiceSettlement non-nil and skip creation. Reference:
handleInvoiceCreditPurchasePostCreate, Charge.State.InvoiceSettlement,
pending.gatheringLine, billingService.CreatePendingInvoiceLines, and the
alternative LinkInvoicedPayment / HandleInvoicePaymentAuthorized hooks mentioned
in comments.
🧹 Nitpick comments (2)
openmeter/billing/charges/service/creditpurchase_test.go (1)

516-522: These DEBUG probes should probably become assertions or go away.

The extra fetches are fine if we want to guard the transition, but right now most of the value is just log noise. If the intermediate invoice state matters, I’d assert it directly; otherwise I’d drop the debug scaffolding before merge.

Also applies to: 539-552

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/billing/charges/service/creditpurchase_test.go` around lines 516 -
522, The two intermediate "DEBUG" fetches in the test use
BillingService.GetStandardInvoiceById (variables betweenInvoice, betweenErr and
the later equivalent) and currently only log results; either remove these extra
fetches entirely or convert them into explicit assertions about the intermediate
invoice state (e.g., assert no error and assert betweenInvoice.Status equals the
expected status) so they provide test value instead of noise—apply the same
change to the similar block referenced around the later case (lines 539-552)
that uses the same GetStandardInvoiceById calls.
openmeter/billing/charges/service/create.go (1)

165-170: Please emit some telemetry when this best-effort step fails.

Since the error is intentionally swallowed here, a stuck invoice-settlement charge becomes invisible to operators. Even a structured log and metric with the charge/customer context would make recovery a lot easier.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/billing/charges/service/create.go` around lines 165 - 170, In the
loop over result.pendingInvoiceCreditPurchases where
s.handleInvoiceCreditPurchasePostCreate(ctx, pending.Value) currently swallows
errors, emit telemetry when err != nil: log a structured error including
identifying fields from pending.Value (e.g. charge ID, customer ID, invoice ID)
and the error, and increment a failure metric/counter (e.g.
invoice_settlement_post_create_failures) tagged with those identifiers; use the
service's existing logger/telemetry client (the same object owning
handleInvoiceCreditPurchasePostCreate, e.g. s.logger or s.metrics) so operators
can detect and filter these best-effort failures even though the code continues
to return and skip processing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@openmeter/billing/charges/service/creditpurchase_test.go`:
- Around line 380-397: The test only exercises the immediate invoice path
because ServicePeriod.From is set to 2026-01-01 (making InvoiceAt <= now); add a
companion test variant that sets the service period
(createCreditPurchaseIntentInput.ServicePeriod.From) to a future date so the
flow creates a pending line first and later links an InvoiceSettlement via the
invoice callback path (exercise
creditpurchase.NewSettlement/creditpurchase.InvoiceSettlement, InvoiceAt branch,
and the pending-to-invoiced transition). In that new test, call
CreateCreditPurchaseIntent with a future ServicePeriod.From, assert that the
initial state is a pending line (no invoice created), then simulate the invoice
callback that attaches the InvoiceSettlement and assert the invoice is
created/linked and balances update accordingly; reuse existing helpers around
CreateCreditPurchaseIntent and assertions to mirror the immediate-path test.

---

Duplicate comments:
In `@openmeter/billing/charges/service/create.go`:
- Around line 245-267: handleInvoiceCreditPurchasePostCreate can create
duplicate pending invoice lines for future-dated charges because
InvoiceSettlement stays nil; ensure idempotency by checking for an existing
gathering line tied to the charge before calling
billingService.CreatePendingInvoiceLines or by persisting a partial
InvoiceSettlement (including the created gathering line ID) immediately after
creating the gathering line. Concretely: in
handleInvoiceCreditPurchasePostCreate, before calling CreatePendingInvoiceLines,
query the billing side for existing gathering lines filtered by ChargeID (or use
an existing lookup method) and return early if one exists; if you must create a
line, write back a durable marker (e.g., set Charge.State.InvoiceSettlement with
gatheringLine ID or persist a minimal settlement record) so subsequent retries
see InvoiceSettlement non-nil and skip creation. Reference:
handleInvoiceCreditPurchasePostCreate, Charge.State.InvoiceSettlement,
pending.gatheringLine, billingService.CreatePendingInvoiceLines, and the
alternative LinkInvoicedPayment / HandleInvoicePaymentAuthorized hooks mentioned
in comments.

---

Nitpick comments:
In `@openmeter/billing/charges/service/create.go`:
- Around line 165-170: In the loop over result.pendingInvoiceCreditPurchases
where s.handleInvoiceCreditPurchasePostCreate(ctx, pending.Value) currently
swallows errors, emit telemetry when err != nil: log a structured error
including identifying fields from pending.Value (e.g. charge ID, customer ID,
invoice ID) and the error, and increment a failure metric/counter (e.g.
invoice_settlement_post_create_failures) tagged with those identifiers; use the
service's existing logger/telemetry client (the same object owning
handleInvoiceCreditPurchasePostCreate, e.g. s.logger or s.metrics) so operators
can detect and filter these best-effort failures even though the code continues
to return and skip processing.

In `@openmeter/billing/charges/service/creditpurchase_test.go`:
- Around line 516-522: The two intermediate "DEBUG" fetches in the test use
BillingService.GetStandardInvoiceById (variables betweenInvoice, betweenErr and
the later equivalent) and currently only log results; either remove these extra
fetches entirely or convert them into explicit assertions about the intermediate
invoice state (e.g., assert no error and assert betweenInvoice.Status equals the
expected status) so they provide test value instead of noise—apply the same
change to the similar block referenced around the later case (lines 539-552)
that uses the same GetStandardInvoiceById calls.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b289c8c6-1441-453d-92e4-afc7b5d5a175

📥 Commits

Reviewing files that changed from the base of the PR and between 4218be2 and 33bf9dc.

📒 Files selected for processing (14)
  • openmeter/billing/adapter/invoiceapp.go
  • openmeter/billing/charges/creditpurchase/adapter.go
  • openmeter/billing/charges/creditpurchase/adapter/charge.go
  • openmeter/billing/charges/creditpurchase/adapter/mapper.go
  • openmeter/billing/charges/creditpurchase/adapter/payment.go
  • openmeter/billing/charges/creditpurchase/chargecreditpurchase.go
  • openmeter/billing/charges/creditpurchase/service.go
  • openmeter/billing/charges/creditpurchase/service/create.go
  • openmeter/billing/charges/creditpurchase/service/invoice.go
  • openmeter/billing/charges/service/create.go
  • openmeter/billing/charges/service/creditpurchase_test.go
  • openmeter/billing/charges/service/invoice.go
  • openmeter/billing/charges/service/invoicependinglines.go
  • openmeter/billing/stdinvoice.go
🚧 Files skipped from review as they are similar to previous changes (9)
  • openmeter/billing/charges/creditpurchase/service/create.go
  • openmeter/billing/charges/creditpurchase/adapter/payment.go
  • openmeter/billing/charges/creditpurchase/adapter/charge.go
  • openmeter/billing/charges/service/invoice.go
  • openmeter/billing/charges/service/invoicependinglines.go
  • openmeter/billing/charges/creditpurchase/chargecreditpurchase.go
  • openmeter/billing/charges/creditpurchase/service.go
  • openmeter/billing/charges/creditpurchase/adapter/mapper.go
  • openmeter/billing/charges/creditpurchase/service/invoice.go

Comment on lines +160 to 174
// Handle invoice credit purchase post-creation steps outside the main transaction
// to avoid lock contention with transactionForInvoiceManipulation.
// This is a best-effort operation - errors are silently ignored since the charge
// has already been committed. The gathering line creation and invoicing can be
// retried later via a background process or manual intervention.
lo.ForEach(result.pendingInvoiceCreditPurchases, func(pending charges.WithIndex[*creditPurchaseWithPendingGatheringLine], _ int) {
updatedCharge, err := s.handleInvoiceCreditPurchasePostCreate(ctx, pending.Value)
if err != nil {
// Skip this charge on error - it has already been committed and can be
// processed later. The charge will not have InvoiceSettlement set.
return
}

out[pending.Index] = charges.NewCharge(updatedCharge)
})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are just gathering lines the same as with other charges. The transactionForInvoiceManipulation is Reentrant lock.

// handleInvoiceCreditPurchasePostCreate handles the post-creation steps for invoice credit purchases.
// This MUST be called outside the Create transaction to avoid lock contention.
// This function is idempotent - it can be safely called multiple times.
func (s *service) handleInvoiceCreditPurchasePostCreate(ctx context.Context, pending *creditPurchaseWithPendingGatheringLine) (creditpurchase.Charge, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move to creditpruchase and follow the same format as with flat fee

Comment on lines +62 to +75
// Set invoice description from the first gathering line if available
if len(inScopeLines) > 0 && inScopeLines[0].Description != nil {
err = s.billingService.UpdateInvoiceFields(ctx, billing.UpdateInvoiceFieldsInput{
Invoice: billing.InvoiceID{
Namespace: createdInvoice.Namespace,
ID: createdInvoice.ID,
},
Description: inScopeLines[0].Description,
})
if err != nil {
return nil, fmt.Errorf("updating invoice description: %w", err)
}
createdInvoice.Description = inScopeLines[0].Description
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not do this we are not sure if there are multiple lines present or not so description can be anything.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-note/feature Release note: Exciting New Features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants