Repository navigation
fix(subscriptions): consume referral coupon on Play purchases made with the referral offer - #200
Conversation
…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>
|
Paired UI PR: thomasluizon/orbit-ui-mobile#162 (merge this API PR first — after the Play Console offers + Render env var are in place). |
There was a problem hiding this comment.
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.
|
* 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>



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.ReferralCouponIddangled forever. The API now recognizes verified purchases made with the developer-determinedreferraloffer and consumes the coupon exactly once.Changes
PlaySubscriptionState.OfferId— surfaced fromlineItems[].offerDetails.offerId(internal model only; no DTO/contract change, verified by contract-aligner)GooglePlaySettings.ReferralOfferId— required key, boot-validated outside DevelopmentIReferralRewardService.CancelCouponAsync— Stripe coupon delete so a Play-redeemed coupon can never be double-redeemed via web checkoutPlayReferralCouponConsumer— 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 rejectedVerifyPlayPurchaseCommand(both entitlement branches, before token link) andHandlePlayNotificationCommand(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
PlayPurchaseTokenis still unlinked.Deploy order (IMPORTANT)
referral10on BOTHorbit_probase plans — developer determined, 10% off a single billing cycle, tagreferral, activatedGooglePlay__ReferralOfferId=referral10before merging (startup validation refuses to boot without it)Paired UI PR: see cross-link comment below.
Refs thomasluizon/orbit-ui-mobile#155
🤖 Generated with Claude Code