Skip to content

Fix: Preserve offer code discount for installments when code is deleted/expired/maxed (#1410) - #3066

Merged
EmCousin merged 14 commits into
antiwork:mainfrom
yashranaway:fix/offer-code-persistence-simplified
Feb 11, 2026
Merged

EmCousin merged 14 commits into
antiwork:mainfrom
yashranaway:fix/offer-code-persistence-simplified

Conversation

@yashranaway

Copy link
Copy Markdown
Contributor

Issue: #1410

Description

Problem

When an offer code hits its max usage limit (or gets deleted/expires) between installment charges, subsequent payments lose the discount even though the customer legitimately used the code on their first payment.

Solution

Instead of checking the live offer code on each charge, we now copy the discount data from the original purchase's purchase_offer_code_discount record. This data was already being stored - we just weren't using it for subsequent charges.

Two changes:

  1. Subscription#build_purchase - copies cached discount data to new purchases
  2. Purchase#original_offer_code - prioritizes cached data over live offer code lookup

Test Results

image image

Added tests for:

  • Deleted offer codes (installments + memberships)
  • Expired offer codes
  • Max usage reached
  • Offer code amount changed after purchase
  • Percentage discounts
  • Backwards compatibility with legacy purchases

Checklist


AI Disclosure

Claude Opus 4.5 was used via Claude Code was used for implementation and test writing.

Comment on lines +251 to +265
if discount_applies_to_next_charge?
if original_purchase.purchase_offer_code_discount.present?
original_discount = original_purchase.purchase_offer_code_discount
purchase.offer_code = original_purchase.offer_code
purchase.build_purchase_offer_code_discount(
offer_code: original_discount.offer_code,
offer_code_amount: original_discount.offer_code_amount,
offer_code_is_percent: original_discount.offer_code_is_percent,
pre_discount_minimum_price_cents: original_discount.pre_discount_minimum_price_cents,
duration_in_months: original_discount.duration_in_months
)
elsif original_purchase.offer_code.present?
purchase.offer_code = original_purchase.offer_code
end
end

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the Core fix - we check if original_purchase.purchase_offer_code_discount exists and copy all fields to the new purchase. Falls back to live offer code for legacy purchases that don't have cached data.

Comment thread app/models/purchase.rb Outdated
Comment on lines +2414 to +2420
return nil if offer_code&.deleted? && !include_deleted

if has_cached_offer_code?
code = purchase_offer_code_discount.offer_code.code
code = purchase_offer_code_discount.offer_code&.code
purchase_offer_code_discount.offer_code_is_percent ?
OfferCode.new(amount_percentage: purchase_offer_code_discount.offer_code_amount, code:) :
OfferCode.new(amount_cents: purchase_offer_code_discount.offer_code_amount, code:)
elsif offer_code&.deleted? && !include_deleted
nil

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reordered the logic - check cached data first, then check if offer code is deleted. Previously we'd return nil for deleted codes before even checking if we had cached data.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test directly covers the reported issue #1410 - offer code with max_purchase_count=1, verify quantity_left <= 0 after first purchase, then confirm discount still applies to next installment.

if discount_applies_to_next_charge?
if original_purchase.purchase_offer_code_discount.present?
original_discount = original_purchase.purchase_offer_code_discount
purchase.offer_code = original_purchase.offer_code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting purchase.offer_code even when it might be nil/deleted - this is intentional for audit trail, the actual discount comes from the cached data.

@yashranaway

Copy link
Copy Markdown
Contributor Author

@EmCousin _a Hey, I have updated the logic as per previous discussion #2257 (comment). Could you please review?

@yashranaway

Copy link
Copy Markdown
Contributor Author

@EmCousin light ping on this.

@slavingia

Copy link
Copy Markdown
Contributor

@EmCousin please review or reassign. @ershad bottleneck to examine?

…istence-simplified

# Conflicts:
#	spec/models/subscription_spec.rb
…tion

Non-first installment payments should not decrement the offer code's max purchase count or fail offer code validation when the code has been deleted/expired/exhausted after the initial purchase.

@EmCousin EmCousin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @yashranaway - non-first installment payments should not decrement the offer code's max purchase count or fail offer code validation when the code has been deleted/expired/exhausted after the initial purchase. I've added the modifications myself, other than that, the PR looks sound.

Let's run CI.

@yashranaway

yashranaway commented Feb 11, 2026 •

Copy link
Copy Markdown
Contributor Author

@EmCousin Thanks for the additions! Makes sense, I have pushed the fixes for failing tests could you run CI once?

@yashranaway

Copy link
Copy Markdown
Contributor Author

@EmCousin 2 flaky UI tests (tiered_membership_offer_codes_spec, payments settings/tax scenarios) and 3 Docker infra timeouts (Wait for services timed out).

@yashranaway

Copy link
Copy Markdown
Contributor Author

@EmCousin Last 1 failing added fix,
image

@yashranaway

Copy link
Copy Markdown
Contributor Author

@EmCousin 3 flaky UI tests (Circle integration, US sales tax, payout settings) All related tests pass!

@EmCousin EmCousin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @yashranaway!

@EmCousin
EmCousin merged commit b9c7979 into antiwork:main Feb 11, 2026
307 of 323 checks passed
@yashranaway
yashranaway deleted the fix/offer-code-persistence-simplified branch February 11, 2026 15:33

This branch had an error being deployed

1 failed deployment
ci-protected — 64b6258b Deployed Feb 11, 2026 by yashranaway via CI Gate #7944
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants