fix(server): should redeem onetime invoice only once (#9927)
fix CLOUD-115
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "invoices" ADD COLUMN "onetime_subscription_redeemed" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -565,6 +565,8 @@ model Invoice {
|
|||||||
lastPaymentError String? @map("last_payment_error") @db.Text
|
lastPaymentError String? @map("last_payment_error") @db.Text
|
||||||
// stripe hosted invoice link
|
// stripe hosted invoice link
|
||||||
link String? @db.Text
|
link String? @db.Text
|
||||||
|
// whether the onetime subscription has been redeemed
|
||||||
|
onetimeSubscriptionRedeemed Boolean @map("onetime_subscription_redeemed") @default(false)
|
||||||
|
|
||||||
@@index([targetId])
|
@@index([targetId])
|
||||||
@@map("invoices")
|
@@map("invoices")
|
||||||
|
|||||||
@@ -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;
|
const { service, db, u1 } = t.context;
|
||||||
|
|
||||||
await service.saveStripeInvoice(onetimeMonthlyInvoice);
|
await service.saveStripeInvoice(onetimeMonthlyInvoice);
|
||||||
@@ -1547,15 +1547,6 @@ test('should be able to recalculate onetime payment subscription period', async
|
|||||||
t.truthy(subInDB);
|
t.truthy(subInDB);
|
||||||
|
|
||||||
let end = subInDB!.end!;
|
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);
|
await service.saveStripeInvoice(onetimeYearlyInvoice);
|
||||||
subInDB = await db.subscription.findFirst({
|
subInDB = await db.subscription.findFirst({
|
||||||
where: { targetId: u1.id },
|
where: { targetId: u1.id },
|
||||||
@@ -1563,6 +1554,16 @@ test('should be able to recalculate onetime payment subscription period', async
|
|||||||
|
|
||||||
// add 365 days
|
// add 365 days
|
||||||
t.is(subInDB!.end!.getTime(), end.getTime() + 365 * 24 * 60 * 60 * 1000);
|
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
|
// make subscription expired
|
||||||
await db.subscription.update({
|
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
|
// TEAM
|
||||||
test('should be able to list prices for team', async t => {
|
test('should be able to list prices for team', async t => {
|
||||||
const { service } = t.context;
|
const { service } = t.context;
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
EventBus,
|
EventBus,
|
||||||
InternalServerError,
|
InternalServerError,
|
||||||
InvalidCheckoutParameters,
|
InvalidCheckoutParameters,
|
||||||
|
Mutex,
|
||||||
Runtime,
|
Runtime,
|
||||||
SubscriptionAlreadyExists,
|
SubscriptionAlreadyExists,
|
||||||
SubscriptionPlanNotFound,
|
SubscriptionPlanNotFound,
|
||||||
|
TooManyRequest,
|
||||||
URLHelper,
|
URLHelper,
|
||||||
} from '../../../base';
|
} from '../../../base';
|
||||||
import {
|
import {
|
||||||
@@ -59,7 +61,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
private readonly runtime: Runtime,
|
private readonly runtime: Runtime,
|
||||||
private readonly feature: FeatureManagementService,
|
private readonly feature: FeatureManagementService,
|
||||||
private readonly event: EventBus,
|
private readonly event: EventBus,
|
||||||
private readonly url: URLHelper
|
private readonly url: URLHelper,
|
||||||
|
private readonly mutex: Mutex
|
||||||
) {
|
) {
|
||||||
super(stripe, db);
|
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: {
|
where: {
|
||||||
stripeInvoiceId: stripeInvoice.id,
|
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
|
// onetime and lifetime subscription is a special "subscription" that doesn't get involved with stripe subscription system
|
||||||
// we track the deals by invoice only.
|
// we track the deals by invoice only.
|
||||||
if (stripeInvoice.status === 'paid') {
|
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) {
|
if (lookupKey.recurring === SubscriptionRecurring.Lifetime) {
|
||||||
await this.saveLifetimeSubscription(knownInvoice);
|
await this.saveLifetimeSubscription(knownInvoice);
|
||||||
} else if (lookupKey.variant === SubscriptionVariant.Onetime) {
|
} else if (lookupKey.variant === SubscriptionVariant.Onetime) {
|
||||||
@@ -429,9 +440,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
return invoice;
|
return invoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveLifetimeSubscription(
|
async saveLifetimeSubscription(knownInvoice: KnownStripeInvoice) {
|
||||||
knownInvoice: KnownStripeInvoice
|
|
||||||
): Promise<Subscription> {
|
|
||||||
this.assertUserIdExists(knownInvoice.userId);
|
this.assertUserIdExists(knownInvoice.userId);
|
||||||
|
|
||||||
// cancel previous non-lifetime subscription
|
// cancel previous non-lifetime subscription
|
||||||
@@ -444,10 +453,9 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let subscription: Subscription;
|
|
||||||
if (prevSubscription) {
|
if (prevSubscription) {
|
||||||
if (prevSubscription.stripeSubscriptionId) {
|
if (prevSubscription.stripeSubscriptionId) {
|
||||||
subscription = await this.db.subscription.update({
|
await this.db.subscription.update({
|
||||||
where: {
|
where: {
|
||||||
id: prevSubscription.id,
|
id: prevSubscription.id,
|
||||||
},
|
},
|
||||||
@@ -469,11 +477,9 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
prorate: true,
|
prorate: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
subscription = prevSubscription;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
subscription = await this.db.subscription.create({
|
await this.db.subscription.create({
|
||||||
data: {
|
data: {
|
||||||
targetId: knownInvoice.userId,
|
targetId: knownInvoice.userId,
|
||||||
stripeSubscriptionId: null,
|
stripeSubscriptionId: null,
|
||||||
@@ -492,17 +498,37 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
plan: knownInvoice.lookupKey.plan,
|
plan: knownInvoice.lookupKey.plan,
|
||||||
recurring: SubscriptionRecurring.Lifetime,
|
recurring: SubscriptionRecurring.Lifetime,
|
||||||
});
|
});
|
||||||
|
|
||||||
return subscription;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveOnetimePaymentSubscription(
|
async saveOnetimePaymentSubscription(knownInvoice: KnownStripeInvoice) {
|
||||||
knownInvoice: KnownStripeInvoice
|
|
||||||
): Promise<Subscription> {
|
|
||||||
this.assertUserIdExists(knownInvoice.userId);
|
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({
|
const existingSubscription = await this.db.subscription.findUnique({
|
||||||
where: {
|
where: {
|
||||||
targetId_plan: {
|
targetId_plan: {
|
||||||
|
|||||||
Reference in New Issue
Block a user