fix: list invoice with invalid status

fix: list invoice with invalid status
This commit is contained in:
Ong Chung Yau 2025-07-08 20:27:58 +08:00 committed by GitHub
parent cc6931ecfe
commit 56c51ac7b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 66 additions and 124 deletions

View File

@ -56,6 +56,10 @@ export class OrganizationService {
return await queryRunner.manager.findOneBy(Organization, { name })
}
public async readOrganizationBySubscriptionId(subscriptionId: typeof Organization.prototype.subscriptionId, queryRunner: QueryRunner) {
return await queryRunner.manager.findOneBy(Organization, { subscriptionId })
}
public async countOrganizations(queryRunner: QueryRunner) {
return await queryRunner.manager.count(Organization)
}

View File

@ -6,9 +6,28 @@ import { Organization, OrganizationStatus } from '../database/entities/organizat
import { OrganizationUser } from '../database/entities/organization-user.entity'
import { Workspace, WorkspaceName } from '../database/entities/workspace.entity'
import { WorkspaceUser } from '../database/entities/workspace-user.entity'
import { OrganizationService } from './organization.service'
import { OrganizationErrorMessage, OrganizationService } from './organization.service'
import logger from '../../utils/logger'
enum SubscriptionStatus {
INCOMPLETE = 'incomplete',
INCOMPLETE_EXPIRED = 'incomplete_expired',
TRIALING = 'trialing',
ACTIVE = 'active',
PAST_DUE = 'past_due',
CANCELED = 'canceled',
UNPAID = 'unpaid',
PAUSED = 'paused'
}
enum InvoiceStatus {
DRAFT = 'draft',
OPEN = 'open',
PAID = 'paid',
UNCOLLECTIBLE = 'uncollectible',
VOID = 'void'
}
// Note: Organization entity will have a 'status' field added later
// This will support values like 'active', 'suspended', etc.
@ -27,7 +46,8 @@ export class StripeService {
return this.stripe
}
public async handleInvoicePaid(invoice: Stripe.Invoice, queryRunner: QueryRunner): Promise<void> {
public async reactivateOrganizationIfEligible(invoice: Stripe.Invoice, queryRunner: QueryRunner): Promise<void> {
try {
await this.getStripe() // Initialize stripe if not already done
if (!invoice.subscription) {
@ -37,73 +57,34 @@ export class StripeService {
const subscriptionId = typeof invoice.subscription === 'string' ? invoice.subscription : invoice.subscription.id
try {
const organization = await queryRunner.manager.findOne(Organization, {
where: { subscriptionId }
})
const organizationService = new OrganizationService()
const organization = await organizationService.readOrganizationBySubscriptionId(subscriptionId, queryRunner)
if (!organization) {
logger.warn(`No organization found for subscription ID: ${subscriptionId}`)
logger.warn(`${OrganizationErrorMessage.ORGANIZATION_NOT_FOUND} for subscription ID: ${subscriptionId}`)
return
}
// Get subscription details from Stripe
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId)
// Always ensure organization is active when invoice is paid
// This handles both reactivation and plan upgrades
const shouldUpdateStatus = (organization as any).status !== OrganizationStatus.ACTIVE
if (shouldUpdateStatus) {
// Check if subscription is past_due - if so, don't reactivate yet
if (subscription.status === 'past_due') {
if (organization.status === OrganizationStatus.ACTIVE) {
logger.info(`Organization ${organization.id} is already active`)
return
}
if (organization.status === OrganizationStatus.UNDER_REVIEW) {
logger.info(`Organization ${organization.id} is under review`)
return
}
// Check for all uncollectible invoices and ensure they are all settled
// Customer must pay/settle ALL uncollectible invoices before reactivation
const uncollectibleInvoices = await this.stripe.invoices.list({
subscription: subscriptionId,
status: 'uncollectible',
limit: 100 // Get all uncollectible invoices
})
if (uncollectibleInvoices.data.length > 0) {
// Check if all uncollectible invoices have been settled (paid)
const unsettledUncollectible = uncollectibleInvoices.data.filter((invoice) => !invoice.paid)
if (unsettledUncollectible.length > 0) {
return
}
}
// Check for any unpaid invoices across all possible unpaid statuses
// This ensures no outstanding debt remains before reactivation
const unpaidStatuses = ['open', 'uncollectible', 'past_due']
let hasUnpaidInvoices = false
let unpaidInvoiceIds: string[] = []
for (const status of unpaidStatuses) {
const invoices = await this.stripe.invoices.list({
subscription: subscriptionId,
status: status as any,
status: InvoiceStatus.UNCOLLECTIBLE,
limit: 100
})
// Filter out invoices that are actually paid (for uncollectible status)
const actuallyUnpaidInvoices = invoices.data.filter((inv) => !inv.paid)
if (actuallyUnpaidInvoices.length > 0) {
hasUnpaidInvoices = true
unpaidInvoiceIds.push(...actuallyUnpaidInvoices.map((inv) => inv.id))
}
}
if (hasUnpaidInvoices) {
logger.info(`Organization ${organization.id} still has unpaid invoices: ${unpaidInvoiceIds.join(', ')}`)
if (uncollectibleInvoices.data.length > 0) {
logger.info(`Organization ${organization.id} has uncollectible invoices`)
return
}
const organizationService = new OrganizationService()
await organizationService.updateOrganization(
{
id: organization.id,
@ -113,48 +94,6 @@ export class StripeService {
queryRunner,
true // fromStripe = true to allow status updates
)
}
// Check if subscription needs to be resumed after all debts are settled
if (subscription.status === 'unpaid') {
// Verify all debts are settled before resuming
// Check for any unpaid invoices across all possible unpaid statuses
// This ensures no outstanding debt remains before reactivation
const allUnpaidStatuses = ['open', 'uncollectible', 'past_due']
let hasAnyUnpaidInvoices = false
let allUnpaidInvoiceIds: string[] = []
for (const status of allUnpaidStatuses) {
const invoices = await this.stripe.invoices.list({
subscription: subscriptionId,
status: status as any,
limit: 100
})
// Filter out invoices that are actually paid (for uncollectible status)
const actuallyUnpaidInvoices = invoices.data.filter((inv) => !inv.paid)
if (actuallyUnpaidInvoices.length > 0) {
hasAnyUnpaidInvoices = true
allUnpaidInvoiceIds.push(...actuallyUnpaidInvoices.map((inv) => inv.id))
}
}
if (!hasAnyUnpaidInvoices) {
// All debts settled - resume the subscription
try {
await this.stripe.subscriptions.update(subscriptionId, {
pause_collection: null // This resumes the subscription
})
logger.info(`Successfully resumed subscription ${subscriptionId}`)
} catch (resumeError) {
logger.error(`Failed to resume subscription ${subscriptionId}: ${resumeError}`)
// Don't throw here - we still want to provision access even if resume fails
}
} else {
logger.info(`Cannot resume subscription ${subscriptionId} - unpaid invoices remain: ${allUnpaidInvoiceIds.join(', ')}`)
}
}
// Always update cache with latest subscription data when invoice is paid
// This ensures access is provisioned for plan upgrades even if org is already active
@ -174,8 +113,7 @@ export class StripeService {
logger.info(`Successfully reactivated organization ${organization.id} and updated cache for subscription ${subscriptionId}`)
} catch (error) {
logger.error(`Error handling invoice paid: ${error}`)
if (queryRunner && queryRunner.isTransactionActive) await queryRunner.rollbackTransaction()
logger.error(`stripe.service.reactivateOrganizationIfEligible: ${error}`)
throw error
}
}

View File

@ -36,7 +36,7 @@ export class StripeWebhooks {
switch (event.type) {
case 'invoice.paid': {
const stripeService = new StripeService()
await stripeService.handleInvoicePaid(event.data.object as Stripe.Invoice, queryRunner)
await stripeService.reactivateOrganizationIfEligible(event.data.object as Stripe.Invoice, queryRunner)
break
}

View File

@ -11,7 +11,7 @@ import { enqueueSnackbar as enqueueSnackbarAction } from '@/store/actions'
// material-ui
import { Button, Dialog, DialogContent, Typography, Stack, DialogActions, CircularProgress, Box } from '@mui/material'
import { IconExternalLink, IconCreditCard, IconLogout, IconHelp, IconX } from '@tabler/icons-react'
import { IconExternalLink, IconCreditCard, IconLogout, IconX } from '@tabler/icons-react'
// API
import accountApi from '@/api/account.api'