mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(api): cancel only the subscription the refund belongs to (#2962)
This commit is contained in:
@@ -261,7 +261,7 @@ export class StripeRefundService {
|
||||
const subscriptionId = refund.metadata.subscription_id;
|
||||
if (subscriptionId) {
|
||||
try {
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund', subscriptionId);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, userId: user.id.toString(), subscriptionId},
|
||||
|
||||
@@ -174,7 +174,7 @@ export class StripeSubscriptionService {
|
||||
}
|
||||
}
|
||||
|
||||
async cancelSubscriptionImmediately(userId: UserID, reason?: string): Promise<void> {
|
||||
async cancelSubscriptionImmediately(userId: UserID, reason?: string, expectedSubscriptionId?: string): Promise<void> {
|
||||
if (!this.stripe) {
|
||||
throw new StripePaymentNotAvailableError();
|
||||
}
|
||||
@@ -185,6 +185,18 @@ export class StripeSubscriptionService {
|
||||
if (!user.stripeSubscriptionId) {
|
||||
throw new StripeNoActiveSubscriptionError();
|
||||
}
|
||||
if (expectedSubscriptionId && user.stripeSubscriptionId !== expectedSubscriptionId) {
|
||||
Logger.info(
|
||||
{
|
||||
userId: user.id.toString(),
|
||||
expectedSubscriptionId,
|
||||
currentSubscriptionId: user.stripeSubscriptionId,
|
||||
reason: reason ?? null,
|
||||
},
|
||||
'Skipping immediate cancellation because the target subscription is no longer the current one',
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const canceledSubscription = await this.stripe.subscriptions.cancel(
|
||||
user.stripeSubscriptionId,
|
||||
|
||||
@@ -487,4 +487,90 @@ describe('StripeRefundService self-serve refund', () => {
|
||||
expect(idempotencyKeys[1]).toContain('retry-1');
|
||||
});
|
||||
});
|
||||
describe('self-serve refund teardown targeting', () => {
|
||||
function trackingSubscriptionDeleteHandler(deleted: Array<string>) {
|
||||
return http.delete(`${STRIPE_API_BASE}/v1/subscriptions/:id`, ({params}) => {
|
||||
deleted.push(String(params.id));
|
||||
return HttpResponse.json({id: params.id, object: 'subscription', status: 'canceled'});
|
||||
});
|
||||
}
|
||||
|
||||
function buildRefundUpdatedEvent(opts: {
|
||||
eventId: string;
|
||||
refundId: string;
|
||||
userId: string;
|
||||
invoiceId: string;
|
||||
subscriptionId: string;
|
||||
}): StripeWebhookEventData {
|
||||
return {
|
||||
id: opts.eventId,
|
||||
type: 'refund.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: opts.refundId,
|
||||
object: 'refund',
|
||||
status: 'succeeded',
|
||||
amount: 2500,
|
||||
currency: 'usd',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: opts.userId,
|
||||
invoice_id: opts.invoiceId,
|
||||
subscription_id: opts.subscriptionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('leaves a newer subscription alone when the refunded one is no longer current', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
const deleted: Array<string> = [];
|
||||
server.use(trackingSubscriptionDeleteHandler(deleted));
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
await setStripeIds(harness, account, {
|
||||
stripe_customer_id: MOCK_CUSTOMER_ID,
|
||||
stripe_subscription_id: 'sub_bought_after_the_refund',
|
||||
});
|
||||
await sendWebhook(
|
||||
buildRefundUpdatedEvent({
|
||||
eventId: 'evt_stale_teardown',
|
||||
refundId: 're_stale_teardown',
|
||||
userId: account.userId,
|
||||
invoiceId: 'in_stale_teardown',
|
||||
subscriptionId: 'sub_refunded_and_already_gone',
|
||||
}),
|
||||
);
|
||||
expect(deleted).toEqual([]);
|
||||
const userRepository = new UserRepository();
|
||||
const user = await userRepository.findUnique(userId);
|
||||
expect(user!.stripeSubscriptionId).toBe('sub_bought_after_the_refund');
|
||||
});
|
||||
|
||||
test('cancels the subscription when the refunded one is still current', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
const deleted: Array<string> = [];
|
||||
server.use(trackingSubscriptionDeleteHandler(deleted));
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
await setStripeIds(harness, account, {
|
||||
stripe_customer_id: MOCK_CUSTOMER_ID,
|
||||
stripe_subscription_id: MOCK_SUBSCRIPTION_ID,
|
||||
});
|
||||
await sendWebhook(
|
||||
buildRefundUpdatedEvent({
|
||||
eventId: 'evt_current_teardown',
|
||||
refundId: 're_current_teardown',
|
||||
userId: account.userId,
|
||||
invoiceId: 'in_current_teardown',
|
||||
subscriptionId: MOCK_SUBSCRIPTION_ID,
|
||||
}),
|
||||
);
|
||||
expect(deleted).toEqual([MOCK_SUBSCRIPTION_ID]);
|
||||
const userRepository = new UserRepository();
|
||||
const user = await userRepository.findUnique(userId);
|
||||
expect(user!.stripeSubscriptionId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user