Repository navigation
Fix: Preserve offer code discount for installments when code is deleted/expired/maxed (#1410) - #3066
Conversation
…leted/expired/maxed
| 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 |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
|
@EmCousin _a Hey, I have updated the logic as per previous discussion #2257 (comment). Could you please review? |
|
@EmCousin light ping on this. |
…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.
…b.com/yashranaway/gumroad into fix/offer-code-persistence-simplified-v2
EmCousin
left a comment
There was a problem hiding this comment.
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.
|
@EmCousin Thanks for the additions! Makes sense, I have pushed the fixes for failing tests could you run CI once? |
|
@EmCousin 2 flaky UI tests (tiered_membership_offer_codes_spec, payments settings/tax scenarios) and 3 Docker infra timeouts (Wait for services timed out). |
|
@EmCousin Last 1 failing added fix, |
|
@EmCousin 3 flaky UI tests (Circle integration, US sales tax, payout settings) All related tests pass! |

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_discountrecord. This data was already being stored - we just weren't using it for subsequent charges.Two changes:
Subscription#build_purchase- copies cached discount data to new purchasesPurchase#original_offer_code- prioritizes cached data over live offer code lookupTest Results
Added tests for:
Checklist
AI Disclosure
Claude Opus 4.5 was used via Claude Code was used for implementation and test writing.