fix(server): should redeem onetime invoice only once (#9927)

fix CLOUD-115
This commit is contained in:
forehalo
2025-02-02 09:18:06 +00:00
parent a673f42073
commit d03447f52e
4 changed files with 76 additions and 27 deletions

View File

@@ -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;

View File

@@ -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<Subscription> {
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<Subscription> {
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: {