diff --git a/packages/backend/server/migrations/20250202080920_record_onetime_subscription_invoices/migration.sql b/packages/backend/server/migrations/20250202080920_record_onetime_subscription_invoices/migration.sql new file mode 100644 index 0000000000..505bb7d4f4 --- /dev/null +++ b/packages/backend/server/migrations/20250202080920_record_onetime_subscription_invoices/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "invoices" ADD COLUMN "onetime_subscription_redeemed" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 47d8edde1c..97daa338a4 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -565,6 +565,8 @@ model Invoice { lastPaymentError String? @map("last_payment_error") @db.Text // stripe hosted invoice link link String? @db.Text + // whether the onetime subscription has been redeemed + onetimeSubscriptionRedeemed Boolean @map("onetime_subscription_redeemed") @default(false) @@index([targetId]) @@map("invoices") diff --git a/packages/backend/server/src/__tests__/payment/service.spec.ts b/packages/backend/server/src/__tests__/payment/service.spec.ts index 334afa39c8..1e4ae8417e 100644 --- a/packages/backend/server/src/__tests__/payment/service.spec.ts +++ b/packages/backend/server/src/__tests__/payment/service.spec.ts @@ -1535,7 +1535,7 @@ test('should be able to subscribe onetime payment subscription', async t => { ); }); -test('should be able to recalculate onetime payment subscription period', async t => { +test('should be able to accumulate onetime payment subscription period', async t => { const { service, db, u1 } = t.context; await service.saveStripeInvoice(onetimeMonthlyInvoice); @@ -1547,15 +1547,6 @@ test('should be able to recalculate onetime payment subscription period', async t.truthy(subInDB); let end = subInDB!.end!; - await service.saveStripeInvoice(onetimeMonthlyInvoice); - subInDB = await db.subscription.findFirst({ - where: { targetId: u1.id }, - }); - - // add 30 days - t.is(subInDB!.end!.getTime(), end.getTime() + 30 * 24 * 60 * 60 * 1000); - - end = subInDB!.end!; await service.saveStripeInvoice(onetimeYearlyInvoice); subInDB = await db.subscription.findFirst({ where: { targetId: u1.id }, @@ -1563,6 +1554,16 @@ test('should be able to recalculate onetime payment subscription period', async // add 365 days t.is(subInDB!.end!.getTime(), end.getTime() + 365 * 24 * 60 * 60 * 1000); +}); + +test('should be able to recalculate onetime payment subscription period after expiration', async t => { + const { service, db, u1 } = t.context; + + await service.saveStripeInvoice(onetimeMonthlyInvoice); + + let subInDB = await db.subscription.findFirst({ + where: { targetId: u1.id }, + }); // make subscription expired await db.subscription.update({ @@ -1583,6 +1584,24 @@ test('should be able to recalculate onetime payment subscription period', async ); }); +test('should not accumulate onetime payment subscription period for redeemed invoices', async t => { + const { service, db, u1 } = t.context; + + // save invoices received more than once, should only redeem them once. + await service.saveStripeInvoice(onetimeYearlyInvoice); + await service.saveStripeInvoice(onetimeYearlyInvoice); + await service.saveStripeInvoice(onetimeYearlyInvoice); + + const subInDB = await db.subscription.findFirst({ + where: { targetId: u1.id }, + }); + + t.is( + subInDB?.end?.toDateString(), + new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toDateString() + ); +}); + // TEAM test('should be able to list prices for team', async t => { const { service } = t.context; diff --git a/packages/backend/server/src/plugins/payment/manager/user.ts b/packages/backend/server/src/plugins/payment/manager/user.ts index f2ec0c66f0..e3e3f39275 100644 --- a/packages/backend/server/src/plugins/payment/manager/user.ts +++ b/packages/backend/server/src/plugins/payment/manager/user.ts @@ -8,9 +8,11 @@ import { EventBus, InternalServerError, InvalidCheckoutParameters, + Mutex, Runtime, SubscriptionAlreadyExists, SubscriptionPlanNotFound, + TooManyRequest, URLHelper, } from '../../../base'; import { @@ -59,7 +61,8 @@ export class UserSubscriptionManager extends SubscriptionManager { private readonly runtime: Runtime, private readonly feature: FeatureManagementService, private readonly event: EventBus, - private readonly url: URLHelper + private readonly url: URLHelper, + private readonly mutex: Mutex ) { super(stripe, db); } @@ -405,7 +408,7 @@ export class UserSubscriptionManager extends SubscriptionManager { }, }); - const invoice = this.db.invoice.upsert({ + const invoice = await this.db.invoice.upsert({ where: { stripeInvoiceId: stripeInvoice.id, }, @@ -419,6 +422,14 @@ export class UserSubscriptionManager extends SubscriptionManager { // onetime and lifetime subscription is a special "subscription" that doesn't get involved with stripe subscription system // we track the deals by invoice only. if (stripeInvoice.status === 'paid') { + await using lock = await this.mutex.acquire( + `redeem-onetime-subscription:${stripeInvoice.id}` + ); + + if (!lock) { + throw new TooManyRequest(); + } + if (lookupKey.recurring === SubscriptionRecurring.Lifetime) { await this.saveLifetimeSubscription(knownInvoice); } else if (lookupKey.variant === SubscriptionVariant.Onetime) { @@ -429,9 +440,7 @@ export class UserSubscriptionManager extends SubscriptionManager { return invoice; } - async saveLifetimeSubscription( - knownInvoice: KnownStripeInvoice - ): Promise { + async saveLifetimeSubscription(knownInvoice: KnownStripeInvoice) { this.assertUserIdExists(knownInvoice.userId); // cancel previous non-lifetime subscription @@ -444,10 +453,9 @@ export class UserSubscriptionManager extends SubscriptionManager { }, }); - let subscription: Subscription; if (prevSubscription) { if (prevSubscription.stripeSubscriptionId) { - subscription = await this.db.subscription.update({ + await this.db.subscription.update({ where: { id: prevSubscription.id, }, @@ -469,11 +477,9 @@ export class UserSubscriptionManager extends SubscriptionManager { prorate: true, } ); - } else { - subscription = prevSubscription; } } else { - subscription = await this.db.subscription.create({ + await this.db.subscription.create({ data: { targetId: knownInvoice.userId, stripeSubscriptionId: null, @@ -492,17 +498,37 @@ export class UserSubscriptionManager extends SubscriptionManager { plan: knownInvoice.lookupKey.plan, recurring: SubscriptionRecurring.Lifetime, }); - - return subscription; } - async saveOnetimePaymentSubscription( - knownInvoice: KnownStripeInvoice - ): Promise { + async saveOnetimePaymentSubscription(knownInvoice: KnownStripeInvoice) { this.assertUserIdExists(knownInvoice.userId); + const { userId, lookupKey, stripeInvoice } = knownInvoice; + + const invoice = await this.db.invoice.findUnique({ + where: { + stripeInvoiceId: stripeInvoice.id, + }, + }); + + if (!invoice) { + // never happens + throw new InternalServerError('Invoice not found'); + } + + if (invoice.onetimeSubscriptionRedeemed) { + return; + } + + await this.db.invoice.update({ + select: { + onetimeSubscriptionRedeemed: true, + }, + where: { + stripeInvoiceId: stripeInvoice.id, + }, + data: { onetimeSubscriptionRedeemed: true }, + }); - // TODO(@forehalo): identify whether the invoice has already been redeemed. - const { userId, lookupKey } = knownInvoice; const existingSubscription = await this.db.subscription.findUnique({ where: { targetId_plan: {