-
Notifications
You must be signed in to change notification settings - Fork 80
fix: prevent $0 payment records for fully paid invoices #1335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
📝 WalkthroughWalkthroughAdded guards in billing run flow to skip creating Payment records when Changes
Sequence Diagram(s)(omitted) Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 3❌ Failed checks (1 warning, 2 inconclusive)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
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. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
🧹 Nitpick comments (1)
platform/flowglad-next/src/subscriptions/billingRunHelpers.ts (1)
593-616: Consider extracting common early-return pattern.The guards at lines 593-616 and 639-662 follow a nearly identical pattern: check a condition, call
processNoMoreDueForBillingPeriod, and return early with the same result shape. While both guards are necessary for different scenarios (one checkstotalDueAmount, the other checksamountToCharge), the repetition could be reduced.♻️ Optional refactor to reduce duplication
Consider extracting a helper function:
const handleNoChargeScenario = async ( billingRun: BillingRun.Record, billingPeriod: BillingPeriod.Record, invoice: Invoice.Record, feeCalculation: FeeCalculation.Record, customer: Customer.Record, organization: Organization.Record, subscription: Subscription.Record, paymentMethod: PaymentMethod.Record, totalDueAmount: number, totalAmountPaid: number, amountToCharge: number, payments: Payment.Record[], transaction: DbTransaction ): Promise<ExecuteBillingRunStepsResult> => { const processBillingPeriodResult = await processNoMoreDueForBillingPeriod( { billingRun, billingPeriod, invoice }, transaction ) return { invoice: processBillingPeriodResult.invoice, feeCalculation, customer, organization, billingPeriod: processBillingPeriodResult.billingPeriod, subscription, paymentMethod, totalDueAmount, totalAmountPaid, amountToCharge, payments, } }Then use it in both guards:
if (totalDueAmount <= 0) { - const processBillingPeriodResult = - await processNoMoreDueForBillingPeriod( - { - billingRun, - billingPeriod, - invoice, - }, - transaction - ) - return { - invoice: processBillingPeriodResult.invoice, - feeCalculation, - customer, - organization, - billingPeriod: processBillingPeriodResult.billingPeriod, - subscription, - paymentMethod, - totalDueAmount, - totalAmountPaid, - amountToCharge, - payments, - } + return handleNoChargeScenario( + billingRun, billingPeriod, invoice, feeCalculation, + customer, organization, subscription, paymentMethod, + totalDueAmount, totalAmountPaid, amountToCharge, payments, + transaction + ) }Also applies to: 639-662
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
platform/flowglad-next/src/components/forms/OrganizationFormFields.tsxplatform/flowglad-next/src/components/ui/form.tsxplatform/flowglad-next/src/subscriptions/billingRunHelpers.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx}: Use idiomatic TypeScript
Avoid type assertions
DO NOT use explicitanytype
Files:
platform/flowglad-next/src/components/forms/OrganizationFormFields.tsxplatform/flowglad-next/src/components/ui/form.tsxplatform/flowglad-next/src/subscriptions/billingRunHelpers.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Avoid code duplication
Leave JSDoc comments for complex functions and APIs
DO NOT use IIFEs (Immediately Invoked Function Expressions)
DO NOT use nested ternaries
Files:
platform/flowglad-next/src/components/forms/OrganizationFormFields.tsxplatform/flowglad-next/src/components/ui/form.tsxplatform/flowglad-next/src/subscriptions/billingRunHelpers.ts
🧠 Learnings (3)
📚 Learning: 2026-01-07T20:17:16.342Z
Learnt from: BrooksFlannery
Repo: flowglad/flowglad PR: 1262
File: platform/flowglad-next/seedDatabase.ts:900-909
Timestamp: 2026-01-07T20:17:16.342Z
Learning: In Zod v4, prefer using z.enum(MyEnum) for TypeScript enums and avoid z.nativeEnum(MyEnum) (the latter is deprecated). When reviewing schemas, verify that z.enum is used with a TypeScript enum (not a string-literal array) and that z.nativeEnum is not used for enums. This guidance applies to all TypeScript files in the codebase where Zod schemas are defined.
Applied to files:
platform/flowglad-next/src/components/forms/OrganizationFormFields.tsxplatform/flowglad-next/src/components/ui/form.tsxplatform/flowglad-next/src/subscriptions/billingRunHelpers.ts
📚 Learning: 2026-01-07T16:33:30.505Z
Learnt from: andresthedesigner
Repo: flowglad/flowglad PR: 1254
File: platform/flowglad-next/src/utils/chartIntervalUtils.ts:71-76
Timestamp: 2026-01-07T16:33:30.505Z
Learning: In the flowglad codebase, avoid suggesting defensive validation (e.g., input guards, Math.abs() for date differences) for internal utility functions when all callers are controlled and pass validated inputs from UI components. Per workspace rules, only make changes that are directly requested or clearly necessary.
Applied to files:
platform/flowglad-next/src/subscriptions/billingRunHelpers.ts
📚 Learning: 2025-12-08T19:35:29.442Z
Learnt from: BrooksFlannery
Repo: flowglad/flowglad PR: 944
File: platform/flowglad-next/src/db/tableMethods/subscriptionMethods.test.ts:536-593
Timestamp: 2025-12-08T19:35:29.442Z
Learning: In test files for subscription methods (platform/flowglad-next/src/db/tableMethods/subscriptionMethods.test.ts), when passing filters with cross-table fields like productName to selectSubscriptionsTableRowData, cast the filters object as SubscriptionTableFilters instead of Record<string, unknown> to maintain type safety while accommodating fields that aren't on the base subscriptions table.
Applied to files:
platform/flowglad-next/src/subscriptions/billingRunHelpers.ts
🧬 Code graph analysis (1)
platform/flowglad-next/src/components/forms/OrganizationFormFields.tsx (1)
platform/flowglad-next/src/components/ui/form.tsx (1)
FormItem(219-219)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: cubic · AI code reviewer
🔇 Additional comments (3)
platform/flowglad-next/src/subscriptions/billingRunHelpers.ts (1)
633-662: Excellent fix for zero-amount payment records!This guard correctly prevents the creation of orphaned $0 payment records when
amountToCharge <= 0(e.g., when an invoice is already fully covered by credits or prior payments). The fix directly addresses issue #1317 by ensuring payment records are only created when there's an actual amount to charge.The implementation is sound:
- Invoice and line items are created
- Billing period and run statuses are updated appropriately via
processNoMoreDueForBillingPeriod- No payment record is created, avoiding the orphaned Processing payment issue
platform/flowglad-next/src/components/ui/form.tsx (1)
77-80: Clean implementation of optional form item IDs.The addition of the optional
formItemIdprop is well-implemented and follows React best practices. The fallback toReact.useId()ensures backward compatibility while allowing explicit IDs to prevent hydration mismatches in SSR scenarios (as indicated by the commit message "fix: hydration mismatch error").The type-safe approach and proper use of the nullish coalescing operator make this a solid enhancement to the FormItem API.
platform/flowglad-next/src/components/forms/OrganizationFormFields.tsx (1)
154-154: Good use of explicit form item IDs.The addition of explicit
formItemIdprops to the FormItem components follows a consistent naming convention ("org-" prefix) and provides semantic, descriptive IDs. This aligns with the fix for hydration mismatch errors by ensuring stable IDs between server and client renders.All IDs are unique within the form, and the changes don't affect validation, submission, or error handling logic.
Also applies to: 167-167, 199-199, 325-325, 369-369
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No issues found across 3 files
|
@agreea review please |
Code Review Feedback1. Please remove the unrelated hydration fixThe This PR should only contain the billing fix for the $0 payment record issue. 2. Please add this test caseThe billing fix looks good, but please add a test case to prevent regression. Here's the test to add in it('does not create a payment record when amountToCharge is 0 due to existing payments fully covering totalDueAmount', async () => {
// Setup: Create a billing period item with a known price
const knownPrice = 10000 // $100 in cents
await adminTransaction(async ({ transaction }) => {
await updateBillingPeriodItem(
{
id: staticBillingPeriodItem.id,
unitPrice: knownPrice,
type: SubscriptionItemType.Static,
},
transaction
)
})
// Create an invoice for this billing period
const testInvoice = await setupInvoice({
billingPeriodId: billingPeriod.id,
customerId: customer.id,
organizationId: organization.id,
priceId: staticPrice.id,
})
// Create an existing payment that FULLY covers the totalDueAmount
// This simulates the scenario from issue #1317 where prior payments
// have already covered the full amount
await setupPayment({
stripeChargeId: 'ch_fullcover_' + core.nanoid(),
status: PaymentStatus.Succeeded,
amount: knownPrice, // Full amount - should result in amountToCharge = 0
livemode: billingPeriod.livemode,
customerId: customer.id,
organizationId: organization.id,
stripePaymentIntentId: 'pi_fullcover_' + core.nanoid(),
invoiceId: testInvoice.id,
paymentMethod: paymentMethod.type,
billingPeriodId: billingPeriod.id,
subscriptionId: billingPeriod.subscriptionId,
paymentMethodId: paymentMethod.id,
})
// Execute the billing run
const result = await adminTransaction(({ transaction }) =>
executeBillingRunCalculationAndBookkeepingSteps(
billingRun,
transaction
)
)
// Verify the scenario: totalDueAmount > 0 but amountToCharge = 0
expect(result.totalDueAmount).toBeGreaterThan(0)
expect(result.totalAmountPaid).toBe(knownPrice)
expect(result.amountToCharge).toBe(0)
// The fix: no payment record should be created when amountToCharge is 0
// Previously this would create an orphaned $0 payment with status: Processing
expect(result.payment).toBeUndefined()
// Verify the invoice is marked as paid
expect(result.invoice.status).toBe(InvoiceStatus.Paid)
// Verify the billing run is marked as succeeded
const updatedBillingRun = await adminTransaction(
({ transaction }) =>
selectBillingRunById(billingRun.id, transaction)
)
expect(updatedBillingRun.status).toBe(
BillingRunStatus.Succeeded
)
})This test verifies that when |
|
sure! |
168679c to
4b09953
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
platform/flowglad-next/src/subscriptions/billingRunHelpers.ts (1)
618-663: Move theamountToCharge <= 0guard before Stripe ID validation.When an invoice is fully paid through prior payments or credits,
amountToChargebecomes 0 (line 471:Math.max(0, totalDueAmount - totalAmountPaid)). In this case, no Stripe charge is needed, so the function should return early without requiring valid Stripe customer or payment method IDs. Currently, the Stripe ID checks (lines 620-630) execute first and throw an error even though no Stripe operation will occur.This causes incorrect billing run failures for legitimately fully-paid invoices when customers lack Stripe credentials—reintroducing the exact problem that the
amountToCharge <= 0guard was designed to prevent.Proposed fix
- const stripeCustomerId = customer.stripeCustomerId - const stripePaymentMethodId = paymentMethod.stripePaymentMethodId - if (!stripeCustomerId) { - throw new Error( - `Cannot run billing for a billing period with a customer that does not have a stripe customer id.` + - ` Customer: ${customer.id}; Billing Period: ${billingPeriod.id}` - ) - } - if (!stripePaymentMethodId) { - throw new Error( - `Cannot run billing for a billing period with a payment method that does not have a stripe payment method id.` + - `Payment Method: ${paymentMethod.id}; Billing Period: ${billingPeriod.id}` - ) - } - /** * Guard: Only create a payment record if there's actually an amount to charge. * This prevents orphaned $0 payment records when the invoice is already fully * paid via credits or prior payments (amountToCharge = 0). * See: https://github.com/flowglad/flowglad/issues/1317 */ if (amountToCharge <= 0) { const processBillingPeriodResult = await processNoMoreDueForBillingPeriod( { billingRun, billingPeriod, invoice, }, transaction ) return { invoice: processBillingPeriodResult.invoice, feeCalculation, customer, organization, billingPeriod: processBillingPeriodResult.billingPeriod, subscription, paymentMethod, totalDueAmount, totalAmountPaid, amountToCharge, payments, } } + + const stripeCustomerId = customer.stripeCustomerId + const stripePaymentMethodId = paymentMethod.stripePaymentMethodId + if (!stripeCustomerId) { + throw new Error( + `Cannot run billing for a billing period with a customer that does not have a stripe customer id.` + + ` Customer: ${customer.id}; Billing Period: ${billingPeriod.id}` + ) + } + if (!stripePaymentMethodId) { + throw new Error( + `Cannot run billing for a billing period with a payment method that does not have a stripe payment method id.` + + `Payment Method: ${paymentMethod.id}; Billing Period: ${billingPeriod.id}` + ) + }Also add a regression test covering
totalDueAmount > 0with prior payments fully covering it (resulting inamountToCharge === 0) and verifying that no Payment record is created.
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
platform/flowglad-next/src/subscriptions/billingRunHelpers.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx}: Use idiomatic TypeScript
Avoid type assertions
DO NOT use explicitanytype
Files:
platform/flowglad-next/src/subscriptions/billingRunHelpers.ts
**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
**/*.{ts,tsx,js,jsx}: Avoid code duplication
Leave JSDoc comments for complex functions and APIs
DO NOT use IIFEs (Immediately Invoked Function Expressions)
DO NOT use nested ternaries
Files:
platform/flowglad-next/src/subscriptions/billingRunHelpers.ts
🧠 Learnings (3)
📚 Learning: 2026-01-07T16:33:30.505Z
Learnt from: andresthedesigner
Repo: flowglad/flowglad PR: 1254
File: platform/flowglad-next/src/utils/chartIntervalUtils.ts:71-76
Timestamp: 2026-01-07T16:33:30.505Z
Learning: In the flowglad codebase, avoid suggesting defensive validation (e.g., input guards, Math.abs() for date differences) for internal utility functions when all callers are controlled and pass validated inputs from UI components. Per workspace rules, only make changes that are directly requested or clearly necessary.
Applied to files:
platform/flowglad-next/src/subscriptions/billingRunHelpers.ts
📚 Learning: 2025-12-08T19:35:29.442Z
Learnt from: BrooksFlannery
Repo: flowglad/flowglad PR: 944
File: platform/flowglad-next/src/db/tableMethods/subscriptionMethods.test.ts:536-593
Timestamp: 2025-12-08T19:35:29.442Z
Learning: In test files for subscription methods (platform/flowglad-next/src/db/tableMethods/subscriptionMethods.test.ts), when passing filters with cross-table fields like productName to selectSubscriptionsTableRowData, cast the filters object as SubscriptionTableFilters instead of Record<string, unknown> to maintain type safety while accommodating fields that aren't on the base subscriptions table.
Applied to files:
platform/flowglad-next/src/subscriptions/billingRunHelpers.ts
📚 Learning: 2026-01-07T20:17:16.342Z
Learnt from: BrooksFlannery
Repo: flowglad/flowglad PR: 1262
File: platform/flowglad-next/seedDatabase.ts:900-909
Timestamp: 2026-01-07T20:17:16.342Z
Learning: In Zod v4, prefer using z.enum(MyEnum) for TypeScript enums and avoid z.nativeEnum(MyEnum) (the latter is deprecated). When reviewing schemas, verify that z.enum is used with a TypeScript enum (not a string-literal array) and that z.nativeEnum is not used for enums. This guidance applies to all TypeScript files in the codebase where Zod schemas are defined.
Applied to files:
platform/flowglad-next/src/subscriptions/billingRunHelpers.ts
✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.
|
@joeysabs is it good to go now? |
|
Hey it looks like coderabbit actually had a good catch that was outside of the diff range if you wanna check it out I had a quick writeup done on it. The Current order (with this PR): Problem: When Fix: Move the guard before line 621: if (totalDueAmount <= 0) {
// ... existing handling
}
+ // Guard: Skip Stripe validation and payment creation when nothing to charge
+ if (amountToCharge <= 0) {
+ const processBillingPeriodResult = await processNoMoreDueForBillingPeriod(
+ { billingRun, billingPeriod, invoice },
+ transaction
+ )
+ return {
+ invoice: processBillingPeriodResult.invoice,
+ feeCalculation,
+ customer,
+ organization,
+ billingPeriod: processBillingPeriodResult.billingPeriod,
+ subscription,
+ paymentMethod,
+ totalDueAmount,
+ totalAmountPaid,
+ amountToCharge,
+ payments,
+ }
+ }
+
const stripeCustomerId = customer.stripeCustomerIdAdditional test needed: it('succeeds without Stripe IDs when amountToCharge is 0', async () => {
// ... same setup as existing test ...
// Remove Stripe IDs - should NOT cause failure when amountToCharge is 0
await adminTransaction(async ({ transaction }) => {
await updatePaymentMethod(
{ id: paymentMethod.id, stripePaymentMethodId: null },
transaction
)
})
// Should succeed, not throw
const result = await adminTransaction(({ transaction }) =>
executeBillingRunCalculationAndBookkeepingSteps(billingRun, transaction)
)
expect(result.amountToCharge).toBe(0)
expect(result.payment).toBeUndefined()
expect(result.invoice.status).toBe(InvoiceStatus.Paid)
}) |
The amountToCharge <= 0 guard must come BEFORE Stripe ID validation because when prior payments fully cover the invoice amount (amountToCharge = 0), we don't need Stripe IDs at all - no payment will be created. Previously, if amountToCharge was 0 but the customer lacked Stripe IDs, the billing run would incorrectly throw an error at the Stripe ID validation before reaching the amountToCharge guard. Also adds a regression test that verifies billing runs succeed without Stripe IDs when amountToCharge is 0. Co-Authored-By: Claude Opus 4.5 <[email protected]>
## What Does this PR Do? <!-- Please provide a clear and concise description of the changes in this PR --> fixes: #1317 <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Prevents $0 payment records for fully paid invoices and fixes a hydration mismatch in the organization form by using stable form item IDs. - **Bug Fixes** - Billing: Skip payment when amountToCharge <= 0 and process the period via processNoMoreDueForBillingPeriod. Move this guard before Stripe ID validation so $0 cases succeed without Stripe IDs. - Forms: Add an optional formItemId to FormItem and set it on organization fields (name, country, payment processing, codebase overview, referral) to keep IDs stable across SSR/CSR. <sup>Written for commit afd531f. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Prevented creation of payment records when amount to charge is zero; invoices and billing runs now transition to paid/succeeded states without initiating a payment flow. * **Tests** * Added tests confirming that when existing payments fully cover dues, no new payment is created and billing/invoice states update as expected, including variants with and without payment provider IDs. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: joeysabs <[email protected]> Co-authored-by: Claude Opus 4.5 <[email protected]>
What Does this PR Do?
fixes: #1317
Summary by cubic
Prevents $0 payment records for fully paid invoices and fixes a hydration mismatch in the organization form by using stable form item IDs.
Written for commit afd531f. Summary will update on new commits.
Summary by CodeRabbit
Bug Fixes
Tests
✏️ Tip: You can customize this high-level summary in your review settings.