Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/models/purchase.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2411,13 +2411,13 @@ def displayed_price_per_unit_cents
end

def original_offer_code(include_deleted: false)
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
Comment on lines -2414 to +2420
Copy link
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.

else
offer_code
end
Expand Down
16 changes: 15 additions & 1 deletion app/models/subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,21 @@ def build_purchase(override_params: {}, from_failed_charge_email: false)
purchase = Purchase.new(purchase_params)
purchase.variant_attributes = original_purchase.variant_attributes

purchase.offer_code = original_purchase.offer_code if discount_applies_to_next_charge?
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
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.

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
Comment on lines +251 to +265
Copy link
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.


purchase.purchaser = user
purchase.link = link
Expand Down
180 changes: 180 additions & 0 deletions spec/models/subscription_spec.rb
Copy link
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.

Original file line number Diff line number Diff line change
Expand Up @@ -3762,4 +3762,184 @@
end
end
end

describe "offer code persistence for subsequent charges" do

describe "installment plans" do
let(:seller) { create(:user) }
let!(:product) { create(:product, user: seller, price_cents: 3000) }
let!(:installment_plan) { create(:product_installment_plan, link: product, number_of_installments: 3) }
let!(:offer_code) { create(:offer_code, products: [product], amount_cents: 500) }
let(:buyer) { create(:user, credit_card: create(:credit_card)) }

context "when offer code is deleted after initial purchase" do
it "preserves the discount for subsequent installments" do
purchase = create(:installment_plan_purchase, link: product, offer_code: offer_code, purchaser: buyer)
subscription = purchase.subscription

offer_code.mark_deleted!

new_purchase = subscription.build_purchase
expect(new_purchase.purchase_offer_code_discount).to be_present
expect(new_purchase.purchase_offer_code_discount.offer_code_amount).to eq(500)
expect(new_purchase.purchase_offer_code_discount.offer_code_is_percent).to be false
end
end

context "when offer code expires after initial purchase" do
it "preserves the discount for subsequent installments" do
offer_code.update!(valid_at: 1.week.ago, expires_at: 1.day.from_now)
purchase = create(:installment_plan_purchase, link: product, offer_code: offer_code, purchaser: buyer)
subscription = purchase.subscription

offer_code.update!(expires_at: 1.day.ago)

new_purchase = subscription.build_purchase
expect(new_purchase.purchase_offer_code_discount).to be_present
expect(new_purchase.purchase_offer_code_discount.offer_code_amount).to eq(500)
end
end

context "when offer code reaches max usage after initial purchase" do
it "preserves the discount for subsequent installments" do
offer_code.update!(max_purchase_count: 1)
purchase = create(:installment_plan_purchase, link: product, offer_code: offer_code, purchaser: buyer)
subscription = purchase.subscription

expect(offer_code.reload.quantity_left).to be <= 0

new_purchase = subscription.build_purchase
expect(new_purchase.purchase_offer_code_discount).to be_present
expect(new_purchase.purchase_offer_code_discount.offer_code_amount).to eq(500)
end
end

context "when offer code amount changes after initial purchase" do
it "uses the original snapshotted amount" do
purchase = create(:installment_plan_purchase, link: product, offer_code: offer_code, purchaser: buyer)
subscription = purchase.subscription

offer_code.update!(amount_cents: 100)

new_purchase = subscription.build_purchase
expect(new_purchase.purchase_offer_code_discount.offer_code_amount).to eq(500)
end
end

context "with percentage discount" do
let!(:percent_offer_code) { create(:offer_code, products: [product], amount_percentage: 25, code: "PERCENT25") }

it "preserves percentage discount when offer code is deleted" do
purchase = create(:installment_plan_purchase, link: product, offer_code: percent_offer_code, purchaser: buyer)
subscription = purchase.subscription

percent_offer_code.mark_deleted!

new_purchase = subscription.build_purchase
expect(new_purchase.purchase_offer_code_discount).to be_present
expect(new_purchase.purchase_offer_code_discount.offer_code_amount).to eq(25)
expect(new_purchase.purchase_offer_code_discount.offer_code_is_percent).to be true
end
end
end

describe "memberships with duration" do
let(:seller) { create(:user) }
let(:product) { create(:membership_product_with_preset_tiered_pricing, user: seller) }
let(:offer_code) { create(:offer_code, products: [product], amount_cents: 100, duration_in_months: 3) }
let(:buyer) { create(:user, credit_card: create(:credit_card)) }

context "when offer code is deleted within duration" do
it "preserves the discount for subsequent charges" do
purchase = create(:membership_purchase, link: product, offer_code: offer_code, purchaser: buyer, variant_attributes: [product.alive_variants.first])
subscription = purchase.subscription
subscription.original_purchase.create_purchase_offer_code_discount!(
offer_code: offer_code,
offer_code_amount: 100,
offer_code_is_percent: false,
pre_discount_minimum_price_cents: 300,
duration_in_months: 3
)

offer_code.mark_deleted!

new_purchase = subscription.build_purchase
expect(new_purchase.purchase_offer_code_discount).to be_present
expect(new_purchase.purchase_offer_code_discount.offer_code_amount).to eq(100)
expect(new_purchase.purchase_offer_code_discount.duration_in_months).to eq(3)
end
end

context "when offer code expires within duration" do
it "preserves the discount for subsequent charges" do
offer_code.update!(valid_at: 1.week.ago, expires_at: 1.day.from_now)
purchase = create(:membership_purchase, link: product, offer_code: offer_code, purchaser: buyer, variant_attributes: [product.alive_variants.first])
subscription = purchase.subscription
subscription.original_purchase.create_purchase_offer_code_discount!(
offer_code: offer_code,
offer_code_amount: 100,
offer_code_is_percent: false,
pre_discount_minimum_price_cents: 300,
duration_in_months: 3
)

offer_code.update!(expires_at: 1.day.ago)

new_purchase = subscription.build_purchase
expect(new_purchase.purchase_offer_code_discount).to be_present
expect(new_purchase.purchase_offer_code_discount.offer_code_amount).to eq(100)
end
end

context "when duration has elapsed" do
it "does not apply the discount" do
purchase = create(:membership_purchase, link: product, offer_code: offer_code, purchaser: buyer, variant_attributes: [product.alive_variants.first])
subscription = purchase.subscription
subscription.original_purchase.create_purchase_offer_code_discount!(
offer_code: offer_code,
offer_code_amount: 100,
offer_code_is_percent: false,
pre_discount_minimum_price_cents: 300,
duration_in_months: 1
)

# Create enough purchases to exceed duration
create(:membership_purchase, subscription: subscription, link: product, purchaser: buyer, variant_attributes: [product.alive_variants.first])

new_purchase = subscription.build_purchase
expect(new_purchase.purchase_offer_code_discount).to be_nil
end
end
end

describe "backwards compatibility" do
let(:seller) { create(:user) }
let(:product) { create(:membership_product_with_preset_tiered_pricing, user: seller) }
let(:offer_code) { create(:offer_code, products: [product], amount_cents: 100) }
let(:buyer) { create(:user, credit_card: create(:credit_card)) }

context "when original purchase has no cached discount (legacy)" do
it "falls back to live offer code" do
purchase = create(:membership_purchase, link: product, offer_code: offer_code, purchaser: buyer, variant_attributes: [product.alive_variants.first])
subscription = purchase.subscription
subscription.original_purchase.purchase_offer_code_discount&.destroy

new_purchase = subscription.build_purchase
expect(new_purchase.offer_code).to eq(offer_code)
expect(new_purchase.purchase_offer_code_discount).to be_nil
end
end

context "when purchase has no offer code" do
it "does not create a discount" do
purchase = create(:membership_purchase, link: product, purchaser: buyer, variant_attributes: [product.alive_variants.first])
subscription = purchase.subscription

new_purchase = subscription.build_purchase
expect(new_purchase.offer_code).to be_nil
expect(new_purchase.purchase_offer_code_discount).to be_nil
end
end
end
end
end