Merge remote-tracking branch 'origin/main'

This commit is contained in:
Weblate
2026-09-25 18:10:57 +00:00
3 changed files with 100 additions and 2 deletions
@@ -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();
});
});
});