Skip to content

feat(billing): email users when their first-purchase bonus window opens and before it closes - #4516

Merged
baktun14 merged 2 commits into
mainfrom
feat/billing-bonus-window-emails
Oct 21, 2026
Merged

baktun14 merged 2 commits into
mainfrom
feat/billing-bonus-window-emails

Conversation

@baktun14

@baktun14 baktun14 commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Why

#4513 keeps the first-purchase bonus open for 7 days after a first purchase under $100, but people only find out on the Add Credits screen. This PR emails them when the window opens and again 2 days before it closes.

Part of CON-1165

What

  • When a personal account's first card payment earns no bonus, the settle path publishes FirstPurchaseBonusWindowOpened after commit.
  • The window check takes the same paying-account and person locks as the bonus check, so two concurrent small first purchases open one window.
  • The event handler re-checks live eligibility. It then emails the close time and queues a reminder for 2 days before the window closes (pg-boss short policy, so one per person and window).
  • The reminder handler re-checks eligibility too, and skips when the window has closed or the bonus was earned.
  • PromotionalNotificationService attaches the signed unsubscribe links from feat(notifications): add an unsubscribe footer and one-click headers to promotional emails #4514. It skips people who opted out of product updates, and sends nothing when EMAIL_UNSUBSCRIBE_SECRET is unset.
  • Team payments open no window. Small purchases made before this ships keep their window from feat(billing): keep the first-purchase bonus open for 7 days after a small first purchase #4513 but get no email.
  • Amplitude: first_purchase_bonus_window_opened and first_purchase_bonus_window_email_sent.

Release: notifications with #4514 (merged) must be live in prod before this API ships. Otherwise these emails go out without the unsubscribe headers and footer.

Testing

  • Unit specs cover both handlers, the templates, the promotional service, the bonus service and the webhook service.
  • Integration specs run both handlers through the real pg-boss worker and the settle path against the database. The webhook functional spec covers a small first purchase.
  • Mutation score on changed lines: 100% (141 of 141 reached mutants killed).

@baktun14
baktun14 requested a review from a team as a code owner October 19, 2026 16:50

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 25 billable files and costs up to $6.25.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 13 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available. Your 57 included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: akash-network/console/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 995ce810-6900-442f-9964-e44cbb35af76
📥 Commits

Reviewing files that changed from the base of the PR and between 85b9b43 and 842e173.

📒 Files selected for processing: 25
  • apps/api/src/app/providers/jobs.provider.ts
  • apps/api/src/app/services/first-purchase-bonus-window-opened/first-purchase-bonus-window-opened.handler.integration.ts
  • apps/api/src/app/services/first-purchase-bonus-window-opened/first-purchase-bonus-window-opened.handler.spec.ts
  • apps/api/src/app/services/first-purchase-bonus-window-opened/first-purchase-bonus-window-opened.handler.ts
  • apps/api/src/app/services/first-purchase-bonus-window-reminder/first-purchase-bonus-window-reminder.handler.integration.ts
  • apps/api/src/app/services/first-purchase-bonus-window-reminder/first-purchase-bonus-window-reminder.handler.spec.ts
  • apps/api/src/app/services/first-purchase-bonus-window-reminder/first-purchase-bonus-window-reminder.handler.ts
  • apps/api/src/app/services/first-purchase-bonus-window-reminder/first-purchase-bonus-window-reminder.job.ts
  • apps/api/src/billing/events/first-purchase-bonus-window-opened.ts
  • apps/api/src/billing/services/first-purchase-bonus/first-purchase-bonus.service.integration.ts
  • apps/api/src/billing/services/first-purchase-bonus/first-purchase-bonus.service.spec.ts
  • apps/api/src/billing/services/first-purchase-bonus/first-purchase-bonus.service.ts
  • apps/api/src/billing/services/stripe-transaction/stripe-transaction.service.integration.ts
  • apps/api/src/billing/services/stripe-transaction/stripe-transaction.service.ts
  • apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.spec.ts
  • apps/api/src/billing/services/stripe-webhook/stripe-webhook.service.ts
  • apps/api/src/core/services/analytics/analytics.service.ts
  • apps/api/src/notifications/services/notification-templates/first-purchase-bonus-window-opened-notification.spec.ts
  • apps/api/src/notifications/services/notification-templates/first-purchase-bonus-window-opened-notification.ts
  • apps/api/src/notifications/services/notification-templates/first-purchase-bonus-window-reminder-notification.spec.ts
  • apps/api/src/notifications/services/notification-templates/first-purchase-bonus-window-reminder-notification.ts
  • apps/api/src/notifications/services/notification-templates/first-purchase-bonus-window-terms.ts
  • apps/api/src/notifications/services/promotional-notification/promotional-notification.service.spec.ts
  • apps/api/src/notifications/services/promotional-notification/promotional-notification.service.ts
  • apps/api/test/functional/stripe-webhook.spec.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No critical issues — one minor suggestion inline.

Reviewed changes

  • Window-open detection on the settle path: FirstPurchaseBonusService.findWindowOpenedBy decides, for a payment that earned no bonus, whether it was a personal account's first card payment, and settleSucceededTransaction returns that decision so StripeWebhookService can publish FirstPurchaseBonusWindowOpened post-commit (idempotent across webhook retries via the settled-status guard).
  • Opened-email handler: re-checks live eligibility (window must still be the open one for the event's reason), tracks Amplitude events with stable insertIds, schedules a short-policy reminder per user+reason with startAfter = close − 2 days, and skips the reminder when less than 2 days remain.
  • Reminder handler: re-checks eligibility and the live close date before sending the "2 days left" email, so bonus earned since, closed windows, and late unsubscribes all suppress it.
  • PromotionalNotificationService: attaches the signed unsubscribe links from #4514, and refuses to send for unsubscribed users or when EMAIL_UNSUBSCRIBE_SECRET is unset (warn log) — the email terms derive from FirstPurchaseBonusService's constants, so copy cannot drift from the rule.
  • Tests: unit specs for both handlers, templates, promotional service, bonus service and webhook service; integration specs running both handlers through the real pg-boss worker; a webhook functional spec covering the small first purchase. I ran the new suites locally — 111 unit, 75 integration, and 18 functional tests all pass, and tsc --noEmit / eslint are clean.

The stacking on #4514 is clean: this diff only consumes the unsubscribe-token and preference plumbing that already exists on the base branch, and the PR body's release-order note (notifications must ship #4514 first) matches the code — an older notifications service strips the unknown unsubscribe field and would send without the headers.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using GLM-5.3 | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

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 prior review's one finding is cleanly fixed.

Reviewed changes

This run covered the delta since the initial review: commit 573bc0d9 ("lock the buyer before opening a first-purchase bonus window").

  • Locked the buyer before opening a window: findWindowOpenedBy now goes through the same #lockPayingAccountThenPerson as the bonus path — paying organization first, then the person, both no key update — so concurrent small first purchases serialize and the loser sees the winner's committed payment and opens nothing. #isPaidByTeam is gone; the team check reads the locked organization row, preserving the missing-org-treated-as-personal semantics, and lock ordering matches every other org/person locker in the codebase (no inversions).
  • Regression and unit coverage: a new integration spec settles one person's two small first purchases in parallel and asserts exactly one window; unit specs assert org→person lock ordering for both entry points and the user-not-lockable 500. The settle-path integration spec now builds its persisted bonus service with real UserRepository/OrganizationRepository so the locks actually run against Postgres.
  • Verified locally: 54 unit + 76 integration tests pass on real Postgres, and the base-checkout check (new test against the pre-fix service) fails with two windows, so the test genuinely catches the race. tsc --noEmit and eslint on the changed files are clean.

Pullfrog  | View workflow run | Using GLM-5.3 | 𝕏

@baktun14

Copy link
Copy Markdown
Contributor Author

@claude review

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

⚠️ Code review skipped — your organization's remaining monthly code review budget is too low to start another review, so this review was not started.

An organization admin can view or raise the monthly spending cap at claude.ai/admin-settings/usage, on the Claude Code Review row under Services. The cap resets at the start of the next billing period.

Once the cap resets or is raised, someone with write access to this repository can comment @claude review on this pull request to trigger a review.

@baktun14
baktun14 force-pushed the feat/billing-bonus-window-emails branch from 573bc0d to eed98d9 Compare October 21, 2026 02:36
@baktun14
baktun14 force-pushed the feat/notifications-promotional-email-unsubscribe branch from c742923 to 4e5cf67 Compare October 21, 2026 03:21
@baktun14
baktun14 force-pushed the feat/billing-bonus-window-emails branch from eed98d9 to 2793a97 Compare October 21, 2026 03:35
Base automatically changed from feat/notifications-promotional-email-unsubscribe to main October 21, 2026 03:39
…ns and before it closes

A personal account's first card payment that earns no bonus now opens the 7-day window
for the bonus, auto-reload charges included. The settle path returns the opening and the
webhook publishes FirstPurchaseBonusWindowOpened after commit, next to the bonus grant.

The event handler re-reads the live eligibility, tracks the opening, schedules a single
reminder 2 days before the window closes and sends the opening email. The reminder runs
on a short queue keyed per person and window, and checks eligibility and the opt-out
again when it is due.

Both emails go through PromotionalNotificationService. It sends nothing to users who
opted out of product update emails or when EMAIL_UNSUBSCRIBE_SECRET is unset. Otherwise
it signs the unsubscribe page and one-click links, which the notifications service turns
into the footer link and the List-Unsubscribe headers.

Part of CON-1165
…ndow

Two small first purchases settling at once each missed the other's payment.
Both opened a window, so the person got two emails.
@baktun14
baktun14 force-pushed the feat/billing-bonus-window-emails branch from 2793a97 to 842e173 Compare October 21, 2026 03:41
@codecov

codecov Bot commented Oct 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.27%. Comparing base (85b9b43) to head (842e173).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4516      +/-   ##
==========================================
+ Coverage   94.05%   94.27%   +0.22%     
==========================================
  Files        1759     1658     -101     
  Lines       50638    47926    -2712     
  Branches    12388    11844     -544     
==========================================
- Hits        47627    45182    -2445     
+ Misses       2708     2457     -251     
+ Partials      303      287      -16     
Flag Coverage Δ *Carryforward flag
api 96.04% <100.00%> (+0.03%) ⬆️
deploy-web 92.90% <ø> (ø) Carriedforward from 85b9b43
log-collector ?
notifications 95.94% <ø> (ø) Carriedforward from 85b9b43
provider-console 81.68% <ø> (ø) Carriedforward from 85b9b43
provider-inventory ?
provider-proxy 92.21% <ø> (ø) Carriedforward from 85b9b43
tx-signer ?

*This pull request uses carry forward flags. Click here to find out more.

Files with missing lines Coverage Δ
apps/api/src/app/providers/jobs.provider.ts 100.00% <ø> (ø)
...ened/first-purchase-bonus-window-opened.handler.ts 100.00% <100.00%> (ø)
...er/first-purchase-bonus-window-reminder.handler.ts 100.00% <100.00%> (ø)
...minder/first-purchase-bonus-window-reminder.job.ts 100.00% <100.00%> (ø)
...lling/events/first-purchase-bonus-window-opened.ts 100.00% <100.00%> (ø)
...rst-purchase-bonus/first-purchase-bonus.service.ts 100.00% <100.00%> (ø)
...s/stripe-transaction/stripe-transaction.service.ts 94.92% <100.00%> (+0.01%) ⬆️
.../services/stripe-webhook/stripe-webhook.service.ts 100.00% <100.00%> (ø)
...i/src/core/services/analytics/analytics.service.ts 100.00% <ø> (ø)
...first-purchase-bonus-window-opened-notification.ts 100.00% <100.00%> (ø)
... and 3 more

... and 111 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@baktun14
baktun14 added this pull request to the merge queue Oct 21, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found — this push is a clean rebase onto main with #4514 merged, and the PR's own code is byte-for-byte what the prior reviews covered.

Reviewed changes

This run covered the delta since the prior pullfrog review (573bc0d): the branch was rebased onto current main, with no change to the billing code itself.

  • Dropped the stacked notifications commits: the branch previously carried the three #4514 commits (unsubscribe footer, header-safe URL validation, postal-address line breaks) beneath the billing work; their content squash-merged into main as 85b9b43, so the PR diff is now the 25 apps/api files alone.
  • Rebased the two billing commits onto the new base unchanged: git range-diff against the pre-push head shows both the feature commit and the buyer-lock commit applying byte-for-byte identically (=), so the settle path, both handlers, the templates, PromotionalNotificationService and every spec are exactly the code reviewed at 573bc0d, including the lock fix for the duplicate-email race.
  • Verified the composition with main's new commits: #4512's additions to jobs.provider.ts coexist with this PR's two handler registrations; CreateNotificationInput's unsubscribe field now resolves from the shared schema #4514 landed in packages/console-api-types; #4515 and #4530 touched deploy-web only; and the rebased head's full check suite passes, including api tests, mutation testing and test-build.

Pullfrog  | View workflow run | Using GLM-5.3 | 𝕏

Merged via the queue into main with commit e387b16 Oct 21, 2026
84 checks passed
@baktun14
baktun14 deleted the feat/billing-bonus-window-emails branch October 21, 2026 03:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant