@@ -35,6 +35,7 @@ import type {
3535 AwsMarketplaceProducts ,
3636} from '@/lib/billing/aws-marketplace/products'
3737import { TERMINAL_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
38+ import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
3839import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
3940import type { DbTransaction } from '@/lib/db/types'
4041
@@ -61,6 +62,9 @@ interface AgreementObservation {
6162 seenAt : Date | null
6263}
6364
65+ /** Why the cancellation was queued; the outbox handler logs it. */
66+ const STRIPE_CANCELLATION_REASON = 'billed-through-aws-marketplace'
67+
6468function enqueueTeamNotice ( tx : DbTransaction , notice : AwsMarketplaceTeamNotice , id ?: string ) {
6569 return enqueueOutboxEvent ( tx , AWS_MARKETPLACE_TEAM_NOTICE_EVENT , notice , { id } )
6670}
@@ -675,34 +679,51 @@ export async function listAccountLicenses(userId: string) {
675679}
676680
677681/**
678- * Whether AWS Marketplace bills this billing reference: a license of a listing this deployment
679- * sells that is not inactive, linked to the organization, or registered to the user's account with
680- * no organization yet.
682+ * A license of a listing this deployment sells that is not inactive and bills `referenceId`: linked
683+ * to the organization, or registered to the user's account with no organization yet.
681684 */
685+ function billsReference ( products : AwsMarketplaceProducts , referenceId : string ) {
686+ return and (
687+ ofProducts ( products ) ,
688+ ne ( awsMarketplaceLicense . status , 'inactive' ) ,
689+ or (
690+ eq ( awsMarketplaceLicense . organizationId , referenceId ) ,
691+ and (
692+ isNull ( awsMarketplaceLicense . organizationId ) ,
693+ eq ( awsMarketplaceLicense . linkedByUserId , referenceId )
694+ )
695+ )
696+ )
697+ }
698+
699+ /** Whether AWS Marketplace bills this billing reference; see {@link billsReference}. */
682700export async function holdsMarketplaceLicense (
683701 products : AwsMarketplaceProducts ,
684702 referenceId : string
685703) : Promise < boolean > {
686704 const [ row ] = await db
687705 . select ( { id : awsMarketplaceLicense . id } )
688706 . from ( awsMarketplaceLicense )
689- . where (
690- and (
691- ofProducts ( products ) ,
692- ne ( awsMarketplaceLicense . status , 'inactive' ) ,
693- or (
694- eq ( awsMarketplaceLicense . organizationId , referenceId ) ,
695- and (
696- isNull ( awsMarketplaceLicense . organizationId ) ,
697- eq ( awsMarketplaceLicense . linkedByUserId , referenceId )
698- )
699- )
700- )
701- )
707+ . where ( billsReference ( products , referenceId ) )
702708 . limit ( 1 )
703709 return Boolean ( row )
704710}
705711
712+ /** The licenses that bill `referenceId`, read in the caller's transaction. */
713+ export async function listLicensesBillingReference (
714+ tx : DbTransaction ,
715+ products : AwsMarketplaceProducts ,
716+ referenceId : string
717+ ) : Promise < string [ ] > {
718+ const rows = await tx
719+ . select ( { id : awsMarketplaceLicense . id } )
720+ . from ( awsMarketplaceLicense )
721+ . where ( billsReference ( products , referenceId ) )
722+ . orderBy ( asc ( awsMarketplaceLicense . createdAt ) )
723+ . limit ( MAX_LICENSES_PER_AGREEMENT )
724+ return rows . map ( ( row ) => row . id )
725+ }
726+
706727/**
707728 * Licenses with no organization, newest first, for the admin dashboard: those waiting for the team
708729 * to link them, and inactive ones that never will be.
@@ -768,6 +789,93 @@ export async function paysForSelfServePlan(
768789 return Boolean ( row )
769790}
770791
792+ /** The billing reference a subscription row names, read without a lock. */
793+ export async function findSubscriptionReference (
794+ tx : DbTransaction ,
795+ subscriptionId : string
796+ ) : Promise < string | null > {
797+ const [ row ] = await tx
798+ . select ( { referenceId : subscription . referenceId } )
799+ . from ( subscription )
800+ . where ( eq ( subscription . id , subscriptionId ) )
801+ . limit ( 1 )
802+ return row ?. referenceId ?? null
803+ }
804+
805+ /**
806+ * Locks a subscription that pays Stripe for a self-serve plan, the kind {@link paysForSelfServePlan}
807+ * refuses to link: bound to a Stripe subscription, not finished, and not Enterprise. Null when the
808+ * row is not, or no longer, one.
809+ */
810+ export async function lockSelfServeStripeSubscription ( tx : DbTransaction , subscriptionId : string ) {
811+ const [ row ] = await tx
812+ . select ( {
813+ id : subscription . id ,
814+ referenceId : subscription . referenceId ,
815+ plan : subscription . plan ,
816+ stripeSubscriptionId : subscription . stripeSubscriptionId ,
817+ } )
818+ . from ( subscription )
819+ . where (
820+ and (
821+ eq ( subscription . id , subscriptionId ) ,
822+ isNotNull ( subscription . stripeSubscriptionId ) ,
823+ notInArray ( subscription . status , [ ...TERMINAL_SUBSCRIPTION_STATUSES ] ) ,
824+ ne ( subscription . plan , 'enterprise' )
825+ )
826+ )
827+ . for ( 'update' )
828+ . limit ( 1 )
829+ if ( ! row ?. stripeSubscriptionId ) return null
830+ return { ...row , stripeSubscriptionId : row . stripeSubscriptionId }
831+ }
832+
833+ /**
834+ * Queues the immediate Stripe cancellation of a subscription AWS Marketplace also bills, and tells
835+ * the team, at most once per subscription: a redelivered webhook finds both already queued. The
836+ * caller holds the subscription row lock.
837+ */
838+ export async function enqueueStripeCancellationForMarketplace (
839+ tx : DbTransaction ,
840+ input : {
841+ subscriptionId : string
842+ stripeSubscriptionId : string
843+ organizationId : string | null
844+ licenseIds : string [ ]
845+ }
846+ ) : Promise < boolean > {
847+ const cancellationId = `aws-marketplace-stripe-cancel-${ input . subscriptionId } `
848+ const [ queued ] = await tx
849+ . select ( { id : outboxEvent . id } )
850+ . from ( outboxEvent )
851+ . where ( eq ( outboxEvent . id , cancellationId ) )
852+ . limit ( 1 )
853+ if ( queued ) return false
854+ await enqueueOutboxEvent (
855+ tx ,
856+ OUTBOX_EVENT_TYPES . STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY ,
857+ {
858+ stripeSubscriptionId : input . stripeSubscriptionId ,
859+ subscriptionId : input . subscriptionId ,
860+ ...( input . organizationId && { organizationId : input . organizationId } ) ,
861+ operationId : cancellationId ,
862+ reason : STRIPE_CANCELLATION_REASON ,
863+ } ,
864+ { id : cancellationId }
865+ )
866+ await enqueueTeamNotice (
867+ tx ,
868+ {
869+ kind : 'stripe-cancelled' ,
870+ licenseIds : input . licenseIds ,
871+ subscriptionId : input . subscriptionId ,
872+ stripeSubscriptionId : input . stripeSubscriptionId ,
873+ } ,
874+ `aws-marketplace-stripe-cancel-notice-${ input . subscriptionId } `
875+ )
876+ return true
877+ }
878+
771879export async function organizationExists ( tx : DbTransaction , organizationId : string ) {
772880 const [ row ] = await tx
773881 . select ( { id : organization . id } )
0 commit comments