Skip to content

fix(billing): allocate stored refunds to one charge - #3256

Merged
johnyeocx merged 1 commit into
john/license-transition-fixesfrom
john/invoice-refund-allocation
Sep 3, 2026
Merged

fix(billing): allocate stored refunds to one charge#3256
johnyeocx merged 1 commit into
john/license-transition-fixesfrom
john/invoice-refund-allocation

Conversation

@johnyeocx

@johnyeocx johnyeocx commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Prevent historical refunds from reducing multiple replacement charges when reconstructing prorated invoice credits.

Co-authored-by: Cursor cursoragent@cursor.com


Stack created with GitHub Stacks CLIGive Feedback 💬


Summary by cubic

Prevents historical refunds from being counted against multiple replacement charges when reconstructing prorated invoice credits. Refunds now allocate only to the latest matching earlier charge, and charges on the same invoice as the refund are excluded, so sequential quantity updates no longer reuse prior refunds against newer replacement charges.

  • Refactors refund computation to allocate each refund to a single charge.
  • Adds an integration test for sequential 5 → 7 → 9 seat quantity updates.
  • Adds unit tests covering single-charge allocation and exclusion of replacement charges.

Written for commit caeaaf9. Summary will update on new commits.

Review in cubic

Greptile Summary

Prevents a historical refund from reducing multiple replacement charges by assigning each refund to one earlier charge.

  • Bug fixes: Allocates stored refunds to the latest matching earlier charge while excluding the replacement charge from the same invoice.
  • Improvements: Adds unit and integration coverage for repeated seat-quantity updates and refund allocation.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains established.

No blocking failure remains.

Important Files Changed

Filename Overview
server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts Replaces per-charge refund summation with single-charge allocation ordered by creation time.
server/src/internal/billing/v2/utils/lineItems/storedInvoiceCreditForPrice.ts Computes the refund allocation map once and applies the assigned amount while calculating each charge’s credit.
server/tests/integration/licenses/billing/update/update-license-quantity.test.ts Adds coverage for sequential 5 → 7 → 9 seat updates.
server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts Updates matcher tests for single-charge allocation, period and price filtering, and replacement-charge exclusion.

Sequence Diagram

sequenceDiagram
    participant Update as Quantity update
    participant Store as Stored line items
    participant Matcher as Refund matcher
    participant Credit as Credit calculation
    Update->>Store: Persist refund and replacement charge
    Store->>Matcher: Load current-period rows
    Matcher->>Matcher: Assign refund to one matching earlier charge
    Matcher->>Credit: Refunded amount by charge ID
    Credit-->>Update: Prorated preview or invoice credit
Loading

Reviews (3): Last reviewed commit: "fix(billing): allocate stored refunds to..." | Re-trigger Greptile

@johnyeocx
johnyeocx requested a review from ay-rod as a code owner September 3, 2026 14:41
@vercel
vercel Bot temporarily deployed to Preview – autumn-vite September 3, 2026 14:41 Inactive
@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated
checkout Ignored Ignored Sep 3, 2026 3:42pm UTC
landing-page Ignored Ignored Sep 3, 2026 3:42pm UTC

Request Review

const matchingCharge = chargeRows
.filter(
(charge) =>
charge.created_at < refund.created_at &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Equal timestamps drop refunds

When a deferred quantity update bulk-inserts its refund and replacement charge, both rows receive the same database created_at; the strict < comparison rejects the charge and leaves the refund unallocated, causing a later preview or update to treat the charge as unrefunded and issue too much credit.

Knowledge Base Used: Billing lifecycle and payment flows

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts
Line: 71

Comment:
**Equal timestamps drop refunds**

When a deferred quantity update bulk-inserts its refund and replacement charge, both rows receive the same database `created_at`; the strict `<` comparison rejects the charge and leaves the refund unallocated, causing a later preview or update to treat the charge as unrefunded and issue too much credit.

**Knowledge Base Used:** [Billing lifecycle and payment flows](https://app.greptile.com/autumn-org-2/-/custom-context/knowledge-base/useautumn/autumn/-/docs/billing-lifecycle.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

4 issues found across 4 files

Confidence score: 2/5

  • server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts uses persisted created_at for ordering, so asynchronous writes can misallocate historical refunds; persist and use Stripe event time or another business chronology.
  • The refund allocation logic in server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts may assign a refund to the latest eligible charge without verifying the actual charge relationship, risking incorrect invoice credits; validate the refund-to-charge correspondence.
  • server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts does not catch refunds being allocated to every matching charge because the invoice-sharing filter excludes the replacement charge; add an assertion that distinguishes single-charge allocation.
  • server/tests/integration/licenses/billing/update/update-license-quantity.test.ts never performs the documented 7-to-9 update, leaving the claimed two-seat billing behavior unverified; execute and assert the second update.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts">

<violation number="1" location="server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts:64">
P1: When invoice rows are persisted asynchronously, `created_at` is storage time rather than Stripe event time, so this ordering can misallocate historical refunds. Persist and use a source/business chronology for allocation, with a deterministic tie-breaker for rows sharing a timestamp.</violation>

<violation number="2" location="server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts:76">
P2: The greedy allocation attributes each refund to the single latest charge created before it (that doesn't share the refund's invoice), without confirming that charge is the one the refund actually corresponds to. When a refund of an earlier charge is not stored on the immediate replacement's invoice — e.g. a proration refund landed on a standalone/separate invoice, or a later replacement charge was created before the refund was recorded — the refund is deducted from the wrong (newest) charge while the charge it genuinely refunds keeps a full credit. This under-credits one charge and over-credits another, which is a billing correctness regression compared to attributing the refund to the charge whose period/price it matches.</violation>
</file>

<file name="server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts">

<violation number="1" location="server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts:188">
P2: This test passes even if computeAlreadyRefundedByCharge allocated a refund to every matching charge, because replacementCharge is filtered out by the invoice-sharing check (both use invoice_id 'inv_update') rather than by the latest-charge selection it claims to verify. Give replacementCharge a distinct invoice_id (e.g. 'inv_other') so it is a genuine candidate, then assert the refund is allocated only to it (the latest, created at PERIOD_START + 500) and not to originalCharge. That would actually guard the one-charge allocation this PR is fixing.</violation>
</file>

<file name="server/tests/integration/licenses/billing/update/update-license-quantity.test.ts">

<violation number="1" location="server/tests/integration/licenses/billing/update/update-license-quantity.test.ts:137">
P2: This test never executes the second update (7 -> 9): after the 5 -> 7 billing.update it only calls previewUpdate and asserts the line-item pair. The docstring and title claim 'the second update bills only 2 seats', but no executed invoice total or Stripe check verifies that, so a regression that only manifests on the execute/finalize path would pass. Execute the 7 -> 9 update and assert the resulting invoice (e.g. latestTotal 2 * DEV_SEAT_PRICE) and expectStripeSubscriptionCorrect, matching the other update tests in this file.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

}): Map<string, number> => {
const alreadyRefundedByCharge = new Map<string, number>();
const chronologicalRefunds = [...refundRows].sort(
(a, b) => a.created_at - b.created_at,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: When invoice rows are persisted asynchronously, created_at is storage time rather than Stripe event time, so this ordering can misallocate historical refunds. Persist and use a source/business chronology for allocation, with a deterministic tie-breaker for rows sharing a timestamp.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts, line 64:

<comment>When invoice rows are persisted asynchronously, `created_at` is storage time rather than Stripe event time, so this ordering can misallocate historical refunds. Persist and use a source/business chronology for allocation, with a deterministic tie-breaker for rows sharing a timestamp.</comment>

<file context>
@@ -49,25 +52,40 @@ export const computeProratedCredit = ({
+}): Map<string, number> => {
+	const alreadyRefundedByCharge = new Map<string, number>();
+	const chronologicalRefunds = [...refundRows].sort(
+		(a, b) => a.created_at - b.created_at,
 	);
 
</file context>

expect(result.size).toBe(0);
});

test("allocates a refund only to the latest matching earlier charge", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This test passes even if computeAlreadyRefundedByCharge allocated a refund to every matching charge, because replacementCharge is filtered out by the invoice-sharing check (both use invoice_id 'inv_update') rather than by the latest-charge selection it claims to verify. Give replacementCharge a distinct invoice_id (e.g. 'inv_other') so it is a genuine candidate, then assert the refund is allocated only to it (the latest, created at PERIOD_START + 500) and not to originalCharge. That would actually guard the one-charge allocation this PR is fixing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts, line 188:

<comment>This test passes even if computeAlreadyRefundedByCharge allocated a refund to every matching charge, because replacementCharge is filtered out by the invoice-sharing check (both use invoice_id 'inv_update') rather than by the latest-charge selection it claims to verify. Give replacementCharge a distinct invoice_id (e.g. 'inv_other') so it is a genuine candidate, then assert the refund is allocated only to it (the latest, created at PERIOD_START + 500) and not to originalCharge. That would actually guard the one-charge allocation this PR is fixing.</comment>

<file context>
@@ -164,16 +173,44 @@ describe(chalk.yellowBright("computeAlreadyRefundedForCharge"), () => {
+		expect(result.size).toBe(0);
+	});
+
+	test("allocates a refund only to the latest matching earlier charge", () => {
+		const originalCharge = makeChargeRow({
+			id: "li_charge_5",
</file context>

},
);

await expectLicenseUpdatePreviewCorrect({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This test never executes the second update (7 -> 9): after the 5 -> 7 billing.update it only calls previewUpdate and asserts the line-item pair. The docstring and title claim 'the second update bills only 2 seats', but no executed invoice total or Stripe check verifies that, so a regression that only manifests on the execute/finalize path would pass. Execute the 7 -> 9 update and assert the resulting invoice (e.g. latestTotal 2 * DEV_SEAT_PRICE) and expectStripeSubscriptionCorrect, matching the other update tests in this file.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/tests/integration/licenses/billing/update/update-license-quantity.test.ts, line 137:

<comment>This test never executes the second update (7 -> 9): after the 5 -> 7 billing.update it only calls previewUpdate and asserts the line-item pair. The docstring and title claim 'the second update bills only 2 seats', but no executed invoice total or Stripe check verifies that, so a regression that only manifests on the execute/finalize path would pass. Execute the 7 -> 9 update and assert the resulting invoice (e.g. latestTotal 2 * DEV_SEAT_PRICE) and expectStripeSubscriptionCorrect, matching the other update tests in this file.</comment>

<file context>
@@ -104,6 +106,45 @@ test.concurrent(
+				},
+			);
+
+		await expectLicenseUpdatePreviewCorrect({
+			preview,
+			customerId,
</file context>

isWithinPeriod(refund, charge) &&
hasSamePrice(refund, charge),
)
.sort((a, b) => b.created_at - a.created_at)[0];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The greedy allocation attributes each refund to the single latest charge created before it (that doesn't share the refund's invoice), without confirming that charge is the one the refund actually corresponds to. When a refund of an earlier charge is not stored on the immediate replacement's invoice — e.g. a proration refund landed on a standalone/separate invoice, or a later replacement charge was created before the refund was recorded — the refund is deducted from the wrong (newest) charge while the charge it genuinely refunds keeps a full credit. This under-credits one charge and over-credits another, which is a billing correctness regression compared to attributing the refund to the charge whose period/price it matches.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts, line 76:

<comment>The greedy allocation attributes each refund to the single latest charge created before it (that doesn't share the refund's invoice), without confirming that charge is the one the refund actually corresponds to. When a refund of an earlier charge is not stored on the immediate replacement's invoice — e.g. a proration refund landed on a standalone/separate invoice, or a later replacement charge was created before the refund was recorded — the refund is deducted from the wrong (newest) charge while the charge it genuinely refunds keeps a full credit. This under-credits one charge and over-credits another, which is a billing correctness regression compared to attributing the refund to the charge whose period/price it matches.</comment>

<file context>
@@ -49,25 +52,40 @@ export const computeProratedCredit = ({
+					isWithinPeriod(refund, charge) &&
+					hasSamePrice(refund, charge),
+			)
+			.sort((a, b) => b.created_at - a.created_at)[0];
+		if (!matchingCharge) continue;
+
</file context>

@johnyeocx
johnyeocx changed the base branch from dev to john/license-transition-fixes September 3, 2026 14:55
@johnyeocx
johnyeocx force-pushed the john/invoice-refund-allocation branch from 81d2e94 to be269cc Compare September 3, 2026 14:55
@vercel
vercel Bot temporarily deployed to Preview – autumn-vite September 3, 2026 14:56 Inactive
Prevent historical refunds from reducing multiple replacement charges when reconstructing prorated invoice credits.

Co-authored-by: Cursor <cursoragent@cursor.com>
@johnyeocx
johnyeocx force-pushed the john/invoice-refund-allocation branch from be269cc to caeaaf9 Compare September 3, 2026 15:42
@vercel
vercel Bot temporarily deployed to Preview – autumn-vite September 3, 2026 15:42 Inactive
@johnyeocx
johnyeocx merged commit d6a3ff1 into dev Sep 3, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant