Skip to content

Grant atomic referral Pro days (ORB-22) - #823

Merged
thomasluizon merged 4 commits into
mainfrom
thomasluizon/ticket-28-referral-pro-days
Oct 20, 2026
Merged

thomasluizon merged 4 commits into
mainfrom
thomasluizon/ticket-28-referral-pro-days

Conversation

@thomasluizon

@thomasluizon thomasluizon commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Closes thomasluizon/orbit-tickets#28. Implements ORB-22.

Referral completion previously saved Rewarded before attempting Stripe delivery. A failed delivery could leave both users without their promised reward. Qualifying referrals now grant seven Pro days to each eligible account without an active paid subscription, in the same transaction as RewardGrantedAtUtc. Active subscribers retain the 10 percent coupon and their exact subscription expiry.

  • User, Referral, ReferralRewardKind and AppConstants hold the entitlement guard, per-party reward choices, delivery checkpoints, seven-day grant and five-reward cap. Incoming and outgoing rewards share that cap, bounding one account to 35 bonus Pro days. Attribution remains capped separately at ten.
  • CheckReferralCompletionCommand counts three distinct local log dates inside the existing completion window, excluding logs before attribution, deleted logs, non-completions and future logs. Transaction-scoped account advisory locks serialize cap checks; the timestamp prevents duplicate grants. Failure recovery reloads only the changed referral and grant recipients, preserving the caller's tracked and pending work in a joined transaction.
  • DeliverReferralRewardJob runs coupon creation/application and push delivery through Hangfire. Jobs are enqueued before the entitlement transaction commits, so queue failure leaves the grant retryable. A worker that sees the referral still pending retries after commit. Coupon creation passes referral-reward-{referralId}-{userId} through the existing StripeCouponRewardService idempotency-key support. A current Play subscription stores its coupon for Play redemption even when a historical Stripe ID remains on the account.
  • Stats and dashboard queries append optional ProDaysPerReferral and report pro_days for accounts without an active paid subscription. MCP describes the selected reward. Controller response annotations generate both OpenAPI contracts.
  • The generated EF migration adds nullable bonus/delivery timestamps and reward-kind columns with defaults. Its repeatable backfill labels historical rewards as delivered coupons without changing entitlements. A bonus-expiry marker distinguishes retained expired provider identifiers from a current paid period and tolerates a renewal written by an older process.

Deploy the API before thomasluizon/orbit-tickets#62. Existing clients continue parsing the unconstrained rewardType string and retain stale discount wording until that client ticket ships; DiscountPercent remains present. Pausing Hangfire delays coupon/push delivery while transactional Pro-day grants continue. Reverting this code does not claw back days already promised. Push can repeat if a worker crashes after sending and before saving its delivery checkpoint.

Assumptions

  • Apply the five-reward cap independently to each participant and include incoming rewards; rejected five outgoing grants plus an additional incoming grant because that exceeds the specified 35-day maximum. An exhausted inviter does not consume the invitee's independent allowance.
  • Preserve the existing seven-day window anchored at account creation; rejected restarting the window at later attribution. The lower log-date boundary uses the invitee's timezone and the referral's creation instant.
  • Preserve signup and subscriber coupon consumers, including plans/checkout/Play redemption; rejected the optional deletion suggestion because ProcessReferralCodeCommand still writes coupon pointers and the subscriber fallback still uses them. Attribution changes are explicitly out of scope.
  • Store reward choices and delivery checkpoints on Referral; rejected a separate ledger table. Reuse the installed coupon service's existing key parameter rather than adding another Stripe integration.

Kept existing SQL column names and mapped them to provider facts, rather than adding duplicate entitlement storage. No external response fields or DTO contracts changed.

None.

Test evidence

  • Unchanged suite with the defect present: dotnet test tests/Orbit.Application.Tests/Orbit.Application.Tests.csproj --filter FullyQualifiedName~CheckReferralCompletionCommandHandlerTests --verbosity minimal passed all 12 original tests.
  • Strengthened regression before the handler fix: dotnet test tests/Orbit.Application.Tests/Orbit.Application.Tests.csproj --no-restore --filter FullyQualifiedName~Handle_ThreeLogsOnSameDay_DoesNotQualify --verbosity minimal failed as intended: expected Pending, observed Rewarded after three same-day logs.
  • After the fix: dotnet test tests/Orbit.Application.Tests/Orbit.Application.Tests.csproj --no-restore --filter 'FullyQualifiedName~CheckReferralCompletionCommandHandlerTests|FullyQualifiedName~GetReferralStatsQueryHandlerTests|FullyQualifiedName~GetReferralDashboardQueryHandlerTests' --verbosity minimal passed 33 tests, including the regression and retained-provider case. Repository queries execute against EF InMemory instead of returning an invented aggregate count.
  • Focused domain checks passed 17 tests. Focused infrastructure/MCP checks passed 17 tests, including a failure injected after the SQL save on SQLite: both entitlements and the rewarded flag roll back, and retry grants exactly once. Concurrent requests use separate contexts and inject DbUpdateConcurrencyException into the loser after the winner commits. Stripe failure leaves the already committed Pro entitlement intact and retries with the same key.
  • Joined-transaction regression: the existing SQL rollback test passed before this case was covered. dotnet test tests/Orbit.Infrastructure.Tests/Orbit.Infrastructure.Tests.csproj --no-restore --filter FullyQualifiedName~Handle_QueueFailureInAmbientTransaction_PreservesCallerTracking --verbosity minimal failed before the cleanup correction: caller language was lost. Strengthening it to include pending caller work also failed with zero persisted notifications instead of one. After scoped recovery, the infrastructure filter for ReferralRewardTransactionTests|FeatureFlagAccountsMigrationTests|ReferralRewardMigrationTests passed all seven tests.
  • Billing-provider regression: the existing coupon delivery tests passed without a Play account retaining an old Stripe ID. dotnet test tests/Orbit.Application.Tests/Orbit.Application.Tests.csproj --no-restore --filter FullyQualifiedName~ExecuteAsync_PlaySubscriberWithRetainedStripeId_StoresCouponForPlay --verbosity minimal failed before the provider guard: the Play coupon pointer was null because delivery targeted the old Stripe subscription. The final focused suite passed all 33 tests, including the corrected Play path.
  • The broad infrastructure run exposed the schema assertion expecting 12 model operations instead of 17. It now explicitly checks the five additional referral columns, safe defaults/nullability, and the new migration while preserving the earlier schema checks.
  • Final validation on the committed tree: dotnet build Orbit.slnx completed with zero errors. Each project passed with dotnet test tests/<project>/<project>.csproj --no-build --no-restore: Domain 780, Application 5,236, Infrastructure 3,776. dotnet test --no-build --no-restore passed the full solution, including 40 analyzer tests, for 9,832 passing tests and zero failures. ORBIT_MIGRATION_GUARD_BASE=origin/main dotnet test tests/Orbit.Infrastructure.Tests/Orbit.Infrastructure.Tests.csproj --no-build --no-restore --filter FullyQualifiedName~RollingMigrationGateTests passed all 59 migration guard tests. Both commits passed the normal formatting, root allowlist, dash, timelessness and suppression hooks.
  • PostgreSQL SQL is generated through Npgsql with migration-history guards. The exact backfill runs twice on SQLite and preserves historical entitlement fields. No server was started under the work order's server ban. Live PostgreSQL execution and advisory-lock contention remain unverified locally; proving those paths requires the generated migration and concurrent qualification on a permitted throwaway PostgreSQL instance.

Unchanged tests passed with the defect present: 9 Domain tests and 80 handler tests.

dotnet test tests/Orbit.Domain.Tests --filter FullyQualifiedName~UserReferralRewardTests --no-restore
dotnet test tests/Orbit.Application.Tests --filter 'FullyQualifiedName~HandleWebhookCommandHandlerTests|FullyQualifiedName~HandlePlayNotificationCommandHandlerTests' --no-restore

New regressions failed before the implementation changed: all 6 Domain cases and all 8 handler cases, both commands exiting 1.

dotnet test tests/Orbit.Domain.Tests --no-restore --filter 'FullyQualifiedName~TerminalProviderCancellationPreservesAccessUntilExpiry|FullyQualifiedName~RepeatedProviderCleanupDoesNotResurrectExpiredBonus|FullyQualifiedName~NewPurchasePreservesIndependentExpiry'
dotnet test tests/Orbit.Application.Tests --no-restore --filter 'FullyQualifiedName~TerminalSubscriptionEvent_PreservesBonusUntilExpiry|FullyQualifiedName~TerminalPlayState_PreservesBonusUntilExpiry'

All regressions now pass. Final verification on the amended commit:

Command Result
dotnet build Orbit.slnx Exit 0, zero errors
dotnet test tests/Orbit.Domain.Tests Exit 0, 786 passed
dotnet test tests/Orbit.Application.Tests Exit 0, 5,247 passed
ORBIT_MIGRATION_GUARD_BASE=origin/main dotnet test tests/Orbit.Infrastructure.Tests Exit 0, 3,787 passed

The initial Infrastructure run exposed an outdated migration inventory assertion. Updated it to include the new migration, then reran successfully. Normal commit hooks passed.

All required verification passed by exit code:

Command Result
dotnet build Orbit.slnx Exit 0, zero errors
Focused migration and referral tests Exit 0, 16 passed
dotnet test tests/Orbit.Domain.Tests Exit 0, 789 passed
dotnet test tests/Orbit.Application.Tests Exit 0, 5,313 passed
ORBIT_MIGRATION_GUARD_BASE=origin/main dotnet test tests/Orbit.Infrastructure.Tests Exit 0, 3,849 passed

From src/Orbit.Api, dotnet ef migrations has-pending-model-changes --project ../Orbit.Infrastructure --startup-project . --no-build exited 0 and reported no pending changes.

Before resolution, this unchanged test exited 1, expecting 17 changes and finding 18:

dotnet test tests/Orbit.Infrastructure.Tests --filter FullyQualifiedName~FeatureFlagAccountsMigrationTests.TargetModel_PreservesRedesign --logger 'console;verbosity=minimal'

After resolution, this command exited 0 with all 16 tests passing:

dotnet test tests/Orbit.Infrastructure.Tests --no-build --filter 'FullyQualifiedName~FeatureFlagAccountsMigrationTests|FullyQualifiedName~SeparateBonusProEntitlementMigrationTests|FullyQualifiedName~ReferralReward|FullyQualifiedName~MakeSubHabitsFreeMigrationTests'

No separate strengthened-test red run was needed: the unchanged test already detected the merge mismatch. All commit hooks passed.

External interface evidence

The installed assemblies were inspected through runtime reflection, rather than guessing response members. Reproduce with a temporary net10.0 console project referencing the installed Hangfire.Core 1.8.25 and Stripe.net 52.4.2 assemblies, and print typeof(IBackgroundJobClient).GetMethod("Create"), typeof(Job).GetProperty("Type"), typeof(Job).GetProperty("Args"), typeof(RequestOptions).GetProperty("IdempotencyKey"), the CouponService.CreateAsync methods, typeof(Coupon).GetProperty("Id"), and the three AutomaticRetryAttribute properties below. The observed schema was:

System.String Create(Hangfire.Common.Job, Hangfire.States.IState)
System.Type Type
System.Collections.Generic.IReadOnlyList`1[System.Object] Args
System.String IdempotencyKey
System.Threading.Tasks.Task`1[Stripe.Coupon] CreateAsync(Stripe.CouponCreateOptions, Stripe.RequestOptions, System.Threading.CancellationToken)
System.String Id
Int32 Attempts
Int32[] DelaysInSeconds
Hangfire.AttemptsExceededAction OnAttemptsExceeded

src/Orbit.Infrastructure/Services/StripeCouponRewardService.cs:17,40 already accepts the optional key and forwards it into RequestOptions.IdempotencyKey; this PR supplies the stable referral/recipient value at the activation delivery callsite. Queue return IDs are not used as a success/failure discriminator. No additional provider response fields are read.

The Hangfire enqueue documentation confirms that enqueue persists the job before returning; the target method runs through a worker. The implementation uses that ordering and retries when a worker observes an uncommitted referral.

Manual steps

None for the owner. Startup applies the migrations; no new settings or backfill. A rollback of the production API to a binary older than this change is the run's step and applies the included Down first (dotnet ef database update 20261020023828_AddReferralProDayRewards against the production connection), because a code-only rollback reads bonus accounts as Free; the run then checks that every bonus row has raw Plan = Pro and PlanExpiresAt covering its bonus expiry.

None introduced by this merge. The commit remains local for the orchestrator to push.

@pullfrog pullfrog 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.

Caution

A delayed expiration or cancellation event can revoke already-granted referral Pro days. Preserve the independent bonus entitlement before merging.

Reviewed changes Reviewed referral qualification, atomic entitlement grants, delivery retries, provider interactions, migrations, and client compatibility across both commits.

  • Qualification and caps: Three distinct local completion dates qualify each participant independently, with ordered account locks and a shared five-reward cap.
  • Atomic Pro grants: Eligible accounts receive seven Pro days together with the referral grant timestamp; active paid subscribers retain coupon rewards.
  • Background delivery: Hangfire delivers coupons and notifications with stable coupon-creation keys and per-recipient checkpoints.
  • Schema and contracts: Additive migration/backfill, referral DTO/OpenAPI additions, and MCP wording; checked the consuming shared schema in orbit-ui-mobile.
  • Verification: All 68 focused application, domain, and infrastructure tests passed. A standalone probe against the real domain code reproduced the entitlement loss for both Stripe and Google Play after ending the account's trial.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using gpt-6.1-sol | 𝕏

Comment thread src/Orbit.Domain/Entities/User.cs Outdated
pullfrog[bot]
pullfrog Bot previously approved these changes Oct 20, 2026

@pullfrog pullfrog 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.

No new issues found. The resolved entitlement-loss finding is fixed, and no new High or Critical issues remain.

Reviewed changes Reviewed 0cc4f0b4 since the prior Pullfrog review, with the full PR diff as context.

  • Separated bonus access: Preserved independently earned Pro days through provider cancellation, expiry, renewal, and new purchases.
  • Updated entitlement consumers: Checked persisted-field mappings, calendar auto-sync, Play ownership comparisons, and deletion restoration against the split entitlement model.
  • Verified migration and regressions: Reviewed repeatable normalization and rollback SQL; all 236 focused domain, application, and infrastructure tests passed locally, including terminal provider callbacks and exact bonus-expiry boundaries.

Pullfrog  | View workflow run | Using gpt-6.1-sol | 𝕏

@pullfrog pullfrog 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.

No new issues found.

Reviewed changes Reviewed the merge reconciliation in c86e2b9b since the prior Pullfrog review at 0cc4f0b4, using the full PR diff for context.

  • Reconciled migration expectations: Updated the model-difference count and migration inventory to include main's interleaved MakeSubHabitsFree migration while preserving both referral migrations.
  • Verified persistence regressions: Ran all 16 focused migration and referral persistence tests successfully; no new PR-specific runtime behavior or contract changes were introduced. The previously resolved entitlement-loss concern remains fixed.

Pullfrog  | View workflow run | Using gpt-6.1-sol | 𝕏

@sonarqubecloud

Copy link
Copy Markdown

@thomasluizon
thomasluizon merged commit 7b4e9af into main Oct 20, 2026
27 checks passed
@thomasluizon
thomasluizon deleted the thomasluizon/ticket-28-referral-pro-days branch October 20, 2026 05:49
thomasluizon added a commit that referenced this pull request Oct 20, 2026
…e sub-habits and referral Pro days (#746) (#829)

* fix: preserve yearly streak gap repairs through recalculation (#818)

* fix: preserve yearly streak gap repairs through recalculation

* fix: isolate yearly streak repair history window

(cherry picked from commit 90c5320)

* fix: expose discounted Stripe billed amount per period (#819)

* fix: expose discounted Stripe billed amount per period

* fix: isolate optional recurring Stripe billing preview

(cherry picked from commit 0460a54)

* Make sub-habits free across every creation path (ORB-231) (#824)

* feat: make sub-habits available to every plan (ORB-231)

* test: track free sub-habits in migration model expectations

* fix: preserve Pro slip alerts when creating free sub-habits

(cherry picked from commit c9d22a6)

* Grant atomic referral Pro days (ORB-22) (#823)

* feat: grant atomic referral Pro days (ORB-22)

* fix: preserve referral transaction and billing context

* fix: preserve bonus Pro access across provider changes

(cherry picked from commit 7b4e9af)

* test: retain combined migration assertions after main carry

* fix: include one-time coupons in billed amount preview (#830)

(cherry picked from commit 377eca5)
thomasluizon added a commit that referenced this pull request Oct 20, 2026
* feat: grant atomic referral Pro days (ORB-22)

* fix: preserve referral transaction and billing context

* fix: preserve bonus Pro access across provider changes

(cherry picked from commit 7b4e9af)
thomasluizon added a commit that referenced this pull request Oct 20, 2026
Carry the redesign-adapted fixes from a80cf56 for #818, #819, #823, #824 and #830 without replaying their main implementations. Preserve ordinary merge ancestry and the production keep-list for thomasluizon/orbit-tickets#1579.
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