Skip to content

fix(subscriptions): consume referral coupon on Play purchases made with the referral offer - #200

Merged
thomasluizon merged 1 commit into
mainfrom
fix/155-play-referral-discount
Jun 10, 2026
Merged

thomasluizon merged 1 commit into
mainfrom
fix/155-play-referral-discount

Conversation

@thomasluizon

Copy link
Copy Markdown
Owner

Summary

Backend half of thomasluizon/orbit-ui-mobile#155 (Option A — full parity): referred users were shown a 10% referral discount while Google Play charged full price, and User.ReferralCouponId dangled forever. The API now recognizes verified purchases made with the developer-determined referral offer and consumes the coupon exactly once.

Changes

  • PlaySubscriptionState.OfferId — surfaced from lineItems[].offerDetails.offerId (internal model only; no DTO/contract change, verified by contract-aligner)
  • GooglePlaySettings.ReferralOfferId — required key, boot-validated outside Development
  • IReferralRewardService.CancelCouponAsync — Stripe coupon delete so a Play-redeemed coupon can never be double-redeemed via web checkout
  • PlayReferralCouponConsumer — consumes only when (1) the referral offer was used, (2) the user holds a coupon, and (3) the purchase token is newly linked. The token-newness rule makes renewals, restores, and re-verifies safe no-ops without notification-type special-casing; a referral-offer purchase without a coupon is accepted + logged, never rejected
  • Wired into VerifyPlayPurchaseCommand (both entitlement branches, before token link) and HandlePlayNotificationCommand (grant path only, before token overwrite)

Tests

3153/3153 pass. New: 7-case consumer matrix (consume / case-insensitive match / base-offer retain / linked-token no-op / no-coupon log path / Stripe-cancel failure resilience / unconfigured no-op) + handler tests capturing that the consumer runs while PlayPurchaseToken is still unlinked.

Deploy order (IMPORTANT)

  1. Play Console: create offer referral10 on BOTH orbit_pro base plans — developer determined, 10% off a single billing cycle, tag referral, activated
  2. Render: set GooglePlay__ReferralOfferId=referral10 before merging (startup validation refuses to boot without it)
  3. Merge this PR, then the paired UI PR

Paired UI PR: see cross-link comment below.
Refs thomasluizon/orbit-ui-mobile#155

🤖 Generated with Claude Code

…th the referral offer

Referred users were promised a 10% referral discount on the upgrade screen
while Google Play charged full price, leaving User.ReferralCouponId dangling
forever. The backend now recognizes purchases made with the developer-determined
referral offer and consumes the coupon exactly once.

- Surface the verified line item's offerId on PlaySubscriptionState
- Add GooglePlay:ReferralOfferId setting (boot-validated outside Development)
- Add IReferralRewardService.CancelCouponAsync (Stripe coupon delete) so a
  Play-redeemed coupon can't be double-redeemed via web checkout later
- PlayReferralCouponConsumer clears + cancels the coupon only when the referral
  offer was used on a purchase token not yet linked to the user, making
  renewals, restores, and re-verifies safe no-ops; referral-offer purchases
  without a coupon are accepted and logged, never rejected
- Wire the consumer into VerifyPlayPurchaseCommand (both entitlement branches)
  and HandlePlayNotificationCommand (grant path only), before token overwrite

Deploy note: set GooglePlay__ReferralOfferId on Render and create the matching
referral offers in Play Console BEFORE merging, or startup validation fails.

Refs thomasluizon/orbit-ui-mobile#155

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@thomasluizon

Copy link
Copy Markdown
Owner Author

Paired UI PR: thomasluizon/orbit-ui-mobile#162 (merge this API PR first — after the Play Console offers + Render env var are in place).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Solid fix for a real billing correctness gap. The PlayReferralCouponConsumer design is clean: token-newness guard makes renewals/re-verifies safe no-ops, null coupon guard handles users who purchased without a coupon, and the best-effort Stripe cancel (OperationCanceledException still propagates, everything else swallowed + logged) is the right tradeoff for a fire-and-forget cleanup step. Ordering is correct in both command handlers — consumer runs before SetPlaySubscription/LinkPlayPurchaseToken, which is both tested and documented on the interface. Boot-time validation of ReferralOfferId prevents silent misconfiguration. Test coverage is comprehensive: 7 dedicated consumer tests plus before-token-overwrite ordering assertions in both command handler test suites.

@sonarqubecloud

Copy link
Copy Markdown

@thomasluizon
thomasluizon merged commit 2767d25 into main Jun 10, 2026
6 checks passed
@thomasluizon
thomasluizon deleted the fix/155-play-referral-discount branch June 10, 2026 19:51
thomasluizon added a commit that referenced this pull request Jun 30, 2026
* feat(api): cooperative challenges domain (#200)

Add a cooperative Challenges domain: Challenge + ChallengeParticipant +
ChallengeParticipantHabit entities (no per-user ranking), CoopGoal (summed
target over a window) and StreakTogether (strict all-must-log shared streak)
types, create/join/leave/detail endpoints with FluentValidation, friend-invite
auto-join + Crockford-base32 join code, and read-side shared-progress
aggregation mirroring HabitMetricsCalculator. Completion + Mission Accomplished
fire from a post-log seam (IChallengeProgressService); Team Player on join.

Team Player / Mission Accomplished award via the conventional string ids passed
to AchievementChecks.TryGrant, which no-op until #196 ships the definitions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(api): integrate main, rescaffold AddChallenges, grant TeamPlayer/MissionAccomplished now #196 ships

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant