-
Notifications
You must be signed in to change notification settings - Fork 244
release #3189
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
release #3189
Changes from 50 commits
e63acf3
c1027d7
c0df6ab
a957834
b57c014
606562f
860dbe5
35c8a37
8b704fd
9821317
832ff54
938d98e
71199be
e5517b3
ad89db1
47f7121
ee654e5
f09c545
d832709
5c6858b
c3e9b63
c21ff2f
aeea69b
61b4ed3
890c082
2bbe338
01b9e07
bf5d24d
fe5dc5a
ff3bb07
ab6c0b5
a2aab8c
bab5fca
30a5c3b
a6b3ea4
c697b23
1106275
26f0790
be37757
c808cda
8f131c2
f0c8064
3d976b1
478c2d6
9360b2b
13d4168
cf4545b
7c954f5
abdd06f
38212f0
dfb3644
2deaaae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,8 @@ import { addDays } from "date-fns"; | |
| import { and, asc, eq, isNotNull, lt, or, sql } from "drizzle-orm"; | ||
| import type { Stripe } from "stripe"; | ||
| import { withStatementTimeout } from "@/db/withStatementTimeout.js"; | ||
| import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js"; | ||
| import { expirePendingCustomerProducts } from "@/internal/billing/v2/execute/expirePendingCustomerProducts"; | ||
| import { OrgService } from "@/internal/orgs/OrgService"; | ||
| import { createStripeCli } from "../../external/connect/createStripeCli"; | ||
| import { stripeInvoiceToStripeSubscriptionId } from "../../external/stripe/invoices/utils/convertStripeInvoice"; | ||
|
|
@@ -69,6 +71,22 @@ export const handleVoidInvoiceCron = async ({ | |
|
|
||
| const subId = stripeInvoiceToStripeSubscriptionId(invoice); | ||
| const voidSub = metadata.type === MetadataType.InvoiceCheckout; | ||
| const expirePendingRows = async () => { | ||
| try { | ||
| await expirePendingCustomerProducts({ | ||
| ctx: { | ||
| db, | ||
| logger, | ||
| org: { id: org.id }, | ||
| env: customer.env, | ||
| redisV2: resolveRedisV2(), | ||
| }, | ||
| metadataId: metadata.id, | ||
| }); | ||
| } catch (error) { | ||
| logger.error(`Error expiring pending customer products: ${error}`); | ||
| } | ||
| }; | ||
|
|
||
| console.log( | ||
| `Invoice: ${metadata.stripe_invoice_id} for customer ${customer.id} (org: ${org.slug}) - status: ${invoice.status}`, | ||
|
|
@@ -90,6 +108,7 @@ export const handleVoidInvoiceCron = async ({ | |
| } | ||
| } | ||
|
|
||
| await expirePendingRows(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When this cron expires a pending product, it updates Postgres without invalidating the full-subject Redis cache. Cached customer reads can therefore continue returning the product as pending until another invalidation; invalidate the affected customer cache during this cleanup. Prompt for AI agents |
||
| await MetadataService.delete({ | ||
| db, | ||
| id: metadata.id, | ||
|
|
@@ -123,6 +142,7 @@ export const handleVoidInvoiceCron = async ({ | |
| } | ||
| } | ||
| } else if (invoice.status === "void" || invoice.status === "uncollectible") { | ||
| await expirePendingRows(); | ||
| await MetadataService.delete({ | ||
| db, | ||
| id: metadata.id, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ import { | |
| } from "@/internal/billing/v2/actions/createSchedule/utils/persistDeferredCreateSchedule"; | ||
| import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan"; | ||
| import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan"; | ||
| import { promotePendingCustomerProducts } from "@/internal/billing/v2/execute/promotePendingCustomerProducts"; | ||
| import { publishBillingTransition } from "@/internal/billing/v2/publish/publishBillingTransition.js"; | ||
| import { buildBillingLockKey } from "@/internal/billing/v2/utils/billingLock/buildBillingLockKey"; | ||
| import { withBillingLock } from "@/internal/billing/v2/utils/billingLock/withBillingLock"; | ||
|
|
@@ -165,6 +166,13 @@ const executeCheckoutSessionMetadataV2 = async ({ | |
| billingContext: updatedDeferredData.billingContext, | ||
| }); | ||
|
|
||
| await promotePendingCustomerProducts({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: For a deferred create-schedule checkout, this call removes the promoted IDs before Prompt for AI agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: After promotion, the reward loop sees an empty Prompt for AI agents |
||
| ctx, | ||
| autumnBillingPlan: updatedDeferredData.billingPlan.autumn, | ||
| fullCustomer: updatedDeferredData.billingContext.fullCustomer, | ||
| metadataId: metadata.id, | ||
| }); | ||
|
|
||
| // Execute autumn billing plan (includes customer products, upsertSubscription, upsertInvoice) | ||
| await executeAutumnBillingPlan({ | ||
| ctx, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,10 @@ | ||
| import { CusProductStatus } from "@autumn/shared"; | ||
| import type Stripe from "stripe"; | ||
| import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; | ||
| import { | ||
| expireCustomerProducts, | ||
| expirePendingCustomerProducts, | ||
| } from "@/internal/billing/v2/execute/expirePendingCustomerProducts"; | ||
| import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; | ||
| import { MetadataService } from "@/internal/metadata/MetadataService"; | ||
|
|
||
|
|
@@ -32,6 +36,10 @@ export const handleStripeCheckoutSessionExpired = async ({ | |
| // Try to clean up the metadata row even if no cusProduct ever got created | ||
| // (e.g. a deferred-flow checkout that expired). | ||
| if (session.metadata?.autumn_metadata_id) { | ||
| await expirePendingCustomerProducts({ | ||
| ctx, | ||
| metadataId: session.metadata.autumn_metadata_id, | ||
| }); | ||
| await MetadataService.delete({ | ||
| db: ctx.db, | ||
| id: session.metadata.autumn_metadata_id, | ||
|
|
@@ -40,21 +48,14 @@ export const handleStripeCheckoutSessionExpired = async ({ | |
| return; | ||
| } | ||
|
|
||
| const now = Date.now(); | ||
|
|
||
| for (const cusProduct of cusProducts) { | ||
| // If the success-path webhook already linked a subscription, leave it. | ||
| if ((cusProduct.subscription_ids ?? []).length > 0) continue; | ||
| const abandonedCusProducts = cusProducts.filter( | ||
| (cusProduct) => (cusProduct.subscription_ids ?? []).length === 0, | ||
| ); | ||
|
|
||
| await CusProductService.update({ | ||
| ctx, | ||
| cusProductId: cusProduct.id, | ||
| updates: { | ||
| status: CusProductStatus.Expired, | ||
| ended_at: now, | ||
| }, | ||
| }); | ||
| } | ||
| await expireCustomerProducts({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: When a session-linked row is still (Based on your team's feedback about conditional expiry of pending rows.) Prompt for AI agents |
||
| ctx, | ||
| customerProducts: abandonedCusProducts, | ||
| }); | ||
|
|
||
| if (session.metadata?.autumn_metadata_id) { | ||
| await MetadataService.delete({ | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,41 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| import type { | ||||||||||||||||||||||||||||||||||||||||||||||
| AttachParamsV1, | ||||||||||||||||||||||||||||||||||||||||||||||
| BillingContext, | ||||||||||||||||||||||||||||||||||||||||||||||
| FullCusProduct, | ||||||||||||||||||||||||||||||||||||||||||||||
| UpdateSubscriptionV1Params, | ||||||||||||||||||||||||||||||||||||||||||||||
| } from "@autumn/shared"; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| export const buildPendingReattachParams = ({ | ||||||||||||||||||||||||||||||||||||||||||||||
| params, | ||||||||||||||||||||||||||||||||||||||||||||||
| billingContext, | ||||||||||||||||||||||||||||||||||||||||||||||
| customerProduct, | ||||||||||||||||||||||||||||||||||||||||||||||
| }: { | ||||||||||||||||||||||||||||||||||||||||||||||
| params: UpdateSubscriptionV1Params; | ||||||||||||||||||||||||||||||||||||||||||||||
| billingContext: BillingContext; | ||||||||||||||||||||||||||||||||||||||||||||||
| customerProduct: FullCusProduct; | ||||||||||||||||||||||||||||||||||||||||||||||
| }): AttachParamsV1 => { | ||||||||||||||||||||||||||||||||||||||||||||||
| const { invoiceMode } = billingContext; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const featureQuantities = | ||||||||||||||||||||||||||||||||||||||||||||||
| params.feature_quantities ?? | ||||||||||||||||||||||||||||||||||||||||||||||
| billingContext.featureQuantities.map((featureQuantity) => ({ | ||||||||||||||||||||||||||||||||||||||||||||||
| feature_id: featureQuantity.feature_id, | ||||||||||||||||||||||||||||||||||||||||||||||
| quantity: featureQuantity.quantity ?? 0, | ||||||||||||||||||||||||||||||||||||||||||||||
| })); | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+20
to
+24
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: When an update changes only one prepaid feature on a pending plan, this nullish selection drops the other feature quantities. Merge requested quantities with Prompt for AI agents
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||
| customer_id: params.customer_id, | ||||||||||||||||||||||||||||||||||||||||||||||
| plan_id: customerProduct.product.id, | ||||||||||||||||||||||||||||||||||||||||||||||
| entity_id: params.entity_id ?? customerProduct.entity_id ?? undefined, | ||||||||||||||||||||||||||||||||||||||||||||||
| feature_quantities: featureQuantities, | ||||||||||||||||||||||||||||||||||||||||||||||
| customize: params.customize, | ||||||||||||||||||||||||||||||||||||||||||||||
| version: params.version, | ||||||||||||||||||||||||||||||||||||||||||||||
| enable_plan_immediately: billingContext.enablePlanImmediately, | ||||||||||||||||||||||||||||||||||||||||||||||
| invoice_mode: invoiceMode && { | ||||||||||||||||||||||||||||||||||||||||||||||
| enabled: true, | ||||||||||||||||||||||||||||||||||||||||||||||
| enable_plan_immediately: invoiceMode.enableProductImmediately, | ||||||||||||||||||||||||||||||||||||||||||||||
| finalize: invoiceMode.finalizeInvoice, | ||||||||||||||||||||||||||||||||||||||||||||||
| net_terms_days: invoiceMode.daysUntilDue, | ||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When the original deferred attach used an invoice template, reattaching its pending product loses the template footer and memo. Preserve the original invoice settings or template identity instead of rebuilding invoice mode with only finalize, immediacy, and terms. Prompt for AI agents |
||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||
| } as AttachParamsV1; | ||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,44 @@ | ||||||||||||||||||||
| import type { FullCusProduct } from "@autumn/shared"; | ||||||||||||||||||||
| import { createStripeCli } from "@/external/connect/createStripeCli"; | ||||||||||||||||||||
| import { expireStripeCheckoutSession } from "@/external/stripe/checkoutSessions/operations/expireStripeCheckoutSession"; | ||||||||||||||||||||
| import { voidStripeInvoiceIfOpen } from "@/external/stripe/invoices/operations/voidStripeInvoiceIfOpen"; | ||||||||||||||||||||
| import type { AutumnContext } from "@/honoUtils/HonoEnv"; | ||||||||||||||||||||
| import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; | ||||||||||||||||||||
| import { MetadataService } from "@/internal/metadata/MetadataService"; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| export const discardPendingCustomerProduct = async ({ | ||||||||||||||||||||
| ctx, | ||||||||||||||||||||
| customerProduct, | ||||||||||||||||||||
| }: { | ||||||||||||||||||||
| ctx: AutumnContext; | ||||||||||||||||||||
| customerProduct: FullCusProduct; | ||||||||||||||||||||
| }) => { | ||||||||||||||||||||
| const metadataId = customerProduct.metadata_id; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if (metadataId) { | ||||||||||||||||||||
| const metadata = await MetadataService.get({ db: ctx.db, id: metadataId }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if (metadata?.stripe_checkout_session_id) { | ||||||||||||||||||||
| await expireStripeCheckoutSession({ | ||||||||||||||||||||
| ctx, | ||||||||||||||||||||
| checkoutSessionId: metadata.stripe_checkout_session_id, | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
Comment on lines
+22
to
+25
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: When the checkout session is completing or already completed, Prompt for AI agents
Suggested change
|
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if (metadata?.stripe_invoice_id) { | ||||||||||||||||||||
| const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); | ||||||||||||||||||||
| const stripeInvoice = await stripeCli.invoices.retrieve( | ||||||||||||||||||||
| metadata.stripe_invoice_id, | ||||||||||||||||||||
| ); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| await voidStripeInvoiceIfOpen({ ctx, stripeInvoice }); | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: When the retrieved invoice is already Prompt for AI agents
Suggested change
|
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| await MetadataService.delete({ db: ctx.db, id: metadataId }); | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: If Prompt for AI agents |
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| await CusProductService.expireIfPending({ | ||||||||||||||||||||
| ctx, | ||||||||||||||||||||
| cusProductId: customerProduct.id, | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| }; | ||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import type { AutumnContext } from "@/honoUtils/HonoEnv"; | ||
| import { discardPendingCustomerProduct } from "@/internal/billing/v2/execute/discardPendingCustomerProduct"; | ||
| import { findPendingCustomerProduct } from "@/internal/billing/v2/execute/findPendingCustomerProduct"; | ||
|
|
||
| export const discardPendingPlanIfAny = async ({ | ||
| ctx, | ||
| customerId, | ||
| productId, | ||
| entityId, | ||
| }: { | ||
| ctx: AutumnContext; | ||
| customerId: string; | ||
| productId?: string; | ||
| entityId?: string; | ||
| }) => { | ||
| const customerProduct = await findPendingCustomerProduct({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: When a customer has more than the configured customer-product limit, this lookup can miss the requested pending plan and continue into active-subscription cancellation. Use an unpaginated pending-product lookup for this path. (Based on your team's feedback about unpaginated pending cancellation.) Prompt for AI agents |
||
| ctx, | ||
| customerId, | ||
| productId, | ||
| entityId, | ||
| }); | ||
|
|
||
| if (!customerProduct) return false; | ||
|
|
||
| await discardPendingCustomerProduct({ ctx, customerProduct }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: When a payment promotion activates the row after the lookup, conditional expiry does nothing but this function still reports that it discarded the plan. Propagate the conditional-expiry result and continue normal cancellation when the row is no longer pending. (Based on your team's feedback about conditional pending expiry.) Prompt for AI agents |
||
| return true; | ||
| }; | ||
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.
P1: When expiration hits a database failure, this catch logs it and returns normally, so the caller deletes metadata and permanently loses the retry path for the pending rows. Propagate the error or delete metadata only after expiration succeeds.
Prompt for AI agents