fix(api): end premium at the real cancellation time and unstick pix refunds (#1985)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 22:43:52 +02:00
1 parent c226eb7211
commit 3445b94af3
5 files changed
+111 -4

No files matched your search

@@ -25,6 +25,7 @@ import type {StripeSubscriptionService} from './StripeSubscriptionService';
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
export const SELF_SERVE_REFUND_WINDOW_DAYS = 3; export const SELF_SERVE_REFUND_WINDOW_DAYS = 3;
export const SELF_SERVE_REFUND_COOLDOWN_DAYS = 30; export const SELF_SERVE_REFUND_COOLDOWN_DAYS = 30;
export const PIX_EXACT_AMOUNT_REFUND_SHORTFALL_CENTS = 1;
interface RefundTarget { interface RefundTarget {
invoice: Stripe.Invoice; invoice: Stripe.Invoice;
@@ -206,6 +207,30 @@ export class StripeRefundService {
}; };
} }
private async resolveChargePaymentMethodType(stripe: Stripe, target: RefundTarget): Promise<string | null> {
try {
if (target.chargeId) {
const charge = await stripe.charges.retrieve(target.chargeId);
return charge.payment_method_details?.type ?? null;
}
if (target.paymentIntentId) {
const paymentIntent = await stripe.paymentIntents.retrieve(target.paymentIntentId, {
expand: ['latest_charge'],
});
const latestCharge = paymentIntent.latest_charge;
if (latestCharge && typeof latestCharge !== 'string') {
return latestCharge.payment_method_details?.type ?? null;
}
}
} catch (error) {
Logger.warn(
{error, chargeId: target.chargeId, paymentIntentId: target.paymentIntentId},
'Failed to resolve payment method type for self-serve refund',
);
}
return null;
}
private async countPriorTerminalFailures(invoiceId: string): Promise<number> { private async countPriorTerminalFailures(invoiceId: string): Promise<number> {
const priorRefunds = await getBillingRepository().refunds.listByInvoice(invoiceId); const priorRefunds = await getBillingRepository().refunds.listByInvoice(invoiceId);
return priorRefunds.filter((r) => r.status === 'failed' || r.status === 'canceled').length; return priorRefunds.filter((r) => r.status === 'failed' || r.status === 'canceled').length;
@@ -273,12 +298,18 @@ export class StripeRefundService {
target.paymentIntentId ?? target.chargeId, target.paymentIntentId ?? target.chargeId,
...(priorFailures > 0 ? [`retry-${priorFailures}`] : []), ...(priorFailures > 0 ? [`retry-${priorFailures}`] : []),
].join(':'); ].join(':');
const paymentMethodType = await this.resolveChargePaymentMethodType(stripe, target);
const isPix = paymentMethodType === 'pix';
const requestedAmountCents =
isPix && target.amountPaidCents > PIX_EXACT_AMOUNT_REFUND_SHORTFALL_CENTS
? target.amountPaidCents - PIX_EXACT_AMOUNT_REFUND_SHORTFALL_CENTS
: target.amountPaidCents;
let refund: Stripe.Response<Stripe.Refund>; let refund: Stripe.Response<Stripe.Refund>;
try { try {
refund = await stripe.refunds.create( refund = await stripe.refunds.create(
{ {
...(target.paymentIntentId ? {payment_intent: target.paymentIntentId} : {charge: target.chargeId!}), ...(target.paymentIntentId ? {payment_intent: target.paymentIntentId} : {charge: target.chargeId!}),
amount: target.amountPaidCents, amount: requestedAmountCents,
reason: 'requested_by_customer', reason: 'requested_by_customer',
metadata: { metadata: {
user_id: user.id.toString(), user_id: user.id.toString(),
@@ -589,8 +589,15 @@ export class StripeSubscriptionWebhookHandler {
premium_billing_cycle: null, premium_billing_cycle: null,
}; };
if (targetUser.premiumType !== UserPremiumTypes.LIFETIME) { if (targetUser.premiumType !== UserPremiumTypes.LIFETIME) {
const anchorMs = Math.max(Date.now(), targetUser.premiumUntil?.getTime() ?? 0); const subscriptionEndedAt = subscription.ended_at ? new Date(subscription.ended_at * 1000) : new Date();
updates.premium_grace_ends_at = new Date(anchorMs + PREMIUM_GRACE_PERIOD_MS); const cancelledBeforePeriodEnd =
targetUser.premiumUntil != null && subscriptionEndedAt.getTime() < targetUser.premiumUntil.getTime();
if (cancelledBeforePeriodEnd) {
updates.premium_until = subscriptionEndedAt;
updates.premium_grace_ends_at = null;
} else {
updates.premium_grace_ends_at = new Date(subscriptionEndedAt.getTime() + PREMIUM_GRACE_PERIOD_MS);
}
} }
const updatedUser = await this.userRepository.patchUpsert(targetUser.id, updates, targetUser.toRow()); const updatedUser = await this.userRepository.patchUpsert(targetUser.id, updates, targetUser.toRow());
await this.dispatchUser(updatedUser); await this.dispatchUser(updatedUser);
@@ -238,6 +238,22 @@ describe('StripeRefundService self-serve refund', () => {
expect(response.refunded_amount_cents).toBe(2500); expect(response.refunded_amount_cents).toBe(2500);
expect(response.subscription_id).toBeNull(); expect(response.subscription_id).toBeNull();
}); });
test('requests one cent under the captured amount when the underlying charge was paid via PIX', async () => {
server.use(
...createStripeApiHandlers({charges: {ch_in_recent: {payment_method_details: {type: 'pix'}}}}).handlers,
);
server.use(
invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY, subscriptionId: null})]),
refundCreateHandler(),
);
const account = await createTestAccount(harness);
await setStripeIds(harness, account, {stripe_customer_id: MOCK_CUSTOMER_ID});
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
.post('/premium/refund-latest')
.execute();
expect(response.invoice_amount_paid_cents).toBe(2500);
expect(response.refunded_amount_cents).toBe(2499);
});
test('refunds latest invoice and cancels subscription when present', async () => { test('refunds latest invoice and cancels subscription when present', async () => {
server.use(...createStripeApiHandlers().handlers); server.use(...createStripeApiHandlers().handlers);
server.use( server.use(
@@ -474,6 +474,56 @@ describe('Stripe Webhook Subscription Lifecycle', () => {
expect(afterUser?.premiumType).toBe(UserPremiumTypes.LIFETIME); expect(afterUser?.premiumType).toBe(UserPremiumTypes.LIFETIME);
expect(afterUser?.stripeSubscriptionId).toBeNull(); expect(afterUser?.stripeSubscriptionId).toBeNull();
}); });
test('cuts premium off at the cancellation moment when the subscription is cancelled before its paid period ends', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const subscriptionId = 'sub_test_cancel_early';
const premiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await userRepository.patchUpsert(
userId,
{
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_since: new Date(),
premium_until: premiumUntil,
},
(await userRepository.findUnique(userId))!.toRow(),
);
const endedAt = Math.floor(Date.now() / 1000);
const eventData = createSubscriptionDeletedEvent({subscriptionId, endedAt});
const result = await sendWebhook(eventData);
expect(result.received).toBe(true);
const afterUser = await userRepository.findUnique(userId);
expect(afterUser?.premiumUntil?.getTime()).toBe(endedAt * 1000);
expect(afterUser?.premiumGraceEndsAt).toBeNull();
expect(afterUser?.stripeSubscriptionId).toBeNull();
});
test('grants standard grace when the subscription is cancelled at the end of its paid period', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const subscriptionId = 'sub_test_cancel_natural';
const premiumUntil = new Date(Date.now() - 60 * 1000);
await userRepository.patchUpsert(
userId,
{
premium_type: UserPremiumTypes.SUBSCRIPTION,
stripe_subscription_id: subscriptionId,
stripe_customer_id: 'cus_test_1',
premium_since: new Date(),
premium_until: premiumUntil,
},
(await userRepository.findUnique(userId))!.toRow(),
);
const endedAt = Math.floor(Date.now() / 1000);
const eventData = createSubscriptionDeletedEvent({subscriptionId, endedAt});
const result = await sendWebhook(eventData);
expect(result.received).toBe(true);
const afterUser = await userRepository.findUnique(userId);
expect(afterUser?.premiumUntil?.getTime()).toBe(premiumUntil.getTime());
expect(afterUser?.premiumGraceEndsAt).not.toBeNull();
expect(afterUser!.premiumGraceEndsAt!.getTime()).toBe(endedAt * 1000 + 3 * 24 * 60 * 60 * 1000);
});
test('processes donation subscription deletion', async () => { test('processes donation subscription deletion', async () => {
const subscriptionId = 'sub_donor_delete_test'; const subscriptionId = 'sub_donor_delete_test';
const donorEmail = '[email protected]'; const donorEmail = '[email protected]';
@@ -1855,7 +1855,9 @@ export function createSubscriptionUpdatedEvent(options: {
export function createSubscriptionDeletedEvent(options: { export function createSubscriptionDeletedEvent(options: {
subscriptionId?: string; subscriptionId?: string;
customerId?: string; customerId?: string;
endedAt?: number;
}): StripeWebhookEventData { }): StripeWebhookEventData {
const nowSeconds = Math.floor(Date.now() / 1000);
return { return {
type: 'customer.subscription.deleted', type: 'customer.subscription.deleted',
data: { data: {
@@ -1864,7 +1866,8 @@ export function createSubscriptionDeletedEvent(options: {
object: 'subscription', object: 'subscription',
customer: options.customerId ?? 'cus_test_1', customer: options.customerId ?? 'cus_test_1',
status: 'canceled', status: 'canceled',
canceled_at: Math.floor(Date.now() / 1000), canceled_at: nowSeconds,
ended_at: options.endedAt ?? nowSeconds,
}, },
}, },
}; };