mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(api): end premium at the real cancellation time and unstick pix refunds (#1985)
This commit is contained in:
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;
|
||||
export const SELF_SERVE_REFUND_WINDOW_DAYS = 3;
|
||||
export const SELF_SERVE_REFUND_COOLDOWN_DAYS = 30;
|
||||
export const PIX_EXACT_AMOUNT_REFUND_SHORTFALL_CENTS = 1;
|
||||
|
||||
interface RefundTarget {
|
||||
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> {
|
||||
const priorRefunds = await getBillingRepository().refunds.listByInvoice(invoiceId);
|
||||
return priorRefunds.filter((r) => r.status === 'failed' || r.status === 'canceled').length;
|
||||
@@ -273,12 +298,18 @@ export class StripeRefundService {
|
||||
target.paymentIntentId ?? target.chargeId,
|
||||
...(priorFailures > 0 ? [`retry-${priorFailures}`] : []),
|
||||
].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>;
|
||||
try {
|
||||
refund = await stripe.refunds.create(
|
||||
{
|
||||
...(target.paymentIntentId ? {payment_intent: target.paymentIntentId} : {charge: target.chargeId!}),
|
||||
amount: target.amountPaidCents,
|
||||
amount: requestedAmountCents,
|
||||
reason: 'requested_by_customer',
|
||||
metadata: {
|
||||
user_id: user.id.toString(),
|
||||
|
||||
@@ -589,8 +589,15 @@ export class StripeSubscriptionWebhookHandler {
|
||||
premium_billing_cycle: null,
|
||||
};
|
||||
if (targetUser.premiumType !== UserPremiumTypes.LIFETIME) {
|
||||
const anchorMs = Math.max(Date.now(), targetUser.premiumUntil?.getTime() ?? 0);
|
||||
updates.premium_grace_ends_at = new Date(anchorMs + PREMIUM_GRACE_PERIOD_MS);
|
||||
const subscriptionEndedAt = subscription.ended_at ? new Date(subscription.ended_at * 1000) : new Date();
|
||||
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());
|
||||
await this.dispatchUser(updatedUser);
|
||||
|
||||
@@ -238,6 +238,22 @@ describe('StripeRefundService self-serve refund', () => {
|
||||
expect(response.refunded_amount_cents).toBe(2500);
|
||||
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 () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
server.use(
|
||||
|
||||
@@ -474,6 +474,56 @@ describe('Stripe Webhook Subscription Lifecycle', () => {
|
||||
expect(afterUser?.premiumType).toBe(UserPremiumTypes.LIFETIME);
|
||||
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 () => {
|
||||
const subscriptionId = 'sub_donor_delete_test';
|
||||
const donorEmail = '[email protected]';
|
||||
|
||||
@@ -1855,7 +1855,9 @@ export function createSubscriptionUpdatedEvent(options: {
|
||||
export function createSubscriptionDeletedEvent(options: {
|
||||
subscriptionId?: string;
|
||||
customerId?: string;
|
||||
endedAt?: number;
|
||||
}): StripeWebhookEventData {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
type: 'customer.subscription.deleted',
|
||||
data: {
|
||||
@@ -1864,7 +1866,8 @@ export function createSubscriptionDeletedEvent(options: {
|
||||
object: 'subscription',
|
||||
customer: options.customerId ?? 'cus_test_1',
|
||||
status: 'canceled',
|
||||
canceled_at: Math.floor(Date.now() / 1000),
|
||||
canceled_at: nowSeconds,
|
||||
ended_at: options.endedAt ?? nowSeconds,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user