Skip to content

feat(purchase): richer plan-step notification email and a veto-window check - #834

Merged
cristim merged 1 commit into
mainfrom
feat/631-pr2b2b-b2
Oct 13, 2026
Merged

cristim merged 1 commit into
mainfrom
feat/631-pr2b2b-b2

Conversation

@cristim

@cristim cristim commented Oct 12, 2026

Copy link
Copy Markdown
Member

What (B2 of #631 PR2b-2b)

  • S12: the scheduled-purchase email states recommendation and unit counts, total commitment (upfront + recurring over each term), the plan cap and the date, plus per recommendation region, AZ, platform, tenancy, term, payment, commitment. An Azure monthly row shows its monthly charge and commitment, not $0. Deviation from the spec sentence: recurring is shown as a total over the terms, since terms can differ per recommendation; each row carries its own monthly and term.
  • S13: PurchasePlan.RequireVetoWindow refuses NotificationDaysBefore < 1. Decision for resume (flagged by the feat(purchase): keep pause, run-now and cancel working on notified plan rows #830 gate): REFUSE. A due recommendation-bearing row of such a plan returns 409 before any change (and again after the CAS as a race backstop); rows with no recommendations or a future date are unaffected. The attach path (B3) calls the same helper before Resolve.
  • B1 follow-up: the lock key builder is split out as planStepLockKeys; a unit test fails if the sort is removed.

Inert

No production caller attaches recommendations; the richer email only renders for rows that carry them. Kill switch off, caps NULL. Rows in the tests are fixtures.

Evidence

  • unit: go test internal/{api,purchase,email,config} pass; make lint 0 issues
  • real PG17 (throwaway local cluster, helper override reverted): resume handler moves the date, cancel-in-between replay, delete releases suppressions, and the new resume-refused-without-veto-window test pass
  • two older tests got a GetExecutionByID mock and a not-due ScheduledDate on the scope-matrix fixture because resume now reads the row first

Refs #631

🤖 Generated with Claude Code

… check

The scheduled-purchase email now states what will be bought: recommendation
and unit counts, total commitment (upfront plus recurring over each term),
the plan cap and the purchase date, and per recommendation the region, AZ,
platform, tenancy, term, payment and commitment. A no-upfront (Azure monthly)
row shows its monthly charge instead of reading as $0.

PurchasePlan.RequireVetoWindow refuses NotificationDaysBefore < 1 (S13). It
is used on resume: a due recommendation-bearing row of such a plan is refused
instead of being moved to now. The attach path will call it too; nothing
attaches yet.

planStepLockKeys is split out of LockPlanProvidersTx so a unit test fails if
the sort is removed.

Refs #631

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@cristim cristim added priority/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/xl Multi-week / refactor type/feat New capability triaged Item has been triaged labels Oct 12, 2026
@coderabbitai

coderabbitai Bot commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 14 billable files and costs up to $3.50.

  • 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 18 minutes for your next included review.

Check out review usage here.

View limit details

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 320da4ae-b342-4a25-a3e9-0c5f5b71e0cd
📥 Commits

Reviewing files that changed from the base of the PR and between 324c0bc and cec9e64.

📒 Files selected for processing (14)
  • internal/api/execution_scope_test.go
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_notified_integration_test.go
  • internal/api/handler_purchases_notified_test.go
  • internal/api/handler_purchases_pause_claim_integration_test.go
  • internal/api/handler_purchases_test.go
  • internal/api/handler_test.go
  • internal/config/plan_veto_window.go
  • internal/config/plan_veto_window_test.go
  • internal/config/store_postgres.go
  • internal/email/sender.go
  • internal/email/templates.go
  • internal/purchase/notification_data_test.go
  • internal/purchase/notifications.go
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@cristim

cristim commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Gate verdict: APPROVE at cec9e64 (independent adversarial review, Opus 5.5 claude-opus-5-5, money path)

Review (code read at the exact head)

  • S12 numbers: internal/purchase/notifications.go:319-353 uses RecommendationRecord.TotalCommitment() (internal/config/commitment.go:10, the same function the step cap uses), so the emailed total matches what the cap checks. Upfront in the summary line is exec.TotalUpfrontCost, which the attach path sets to the sum of rec UpfrontCost (attach_recommendations.go:27-29), so total = upfront + recurring holds. An Azure monthly row renders Upfront $0.00 + $100.00/month | Commitment $1200.00, not $0. Cap, date, units, region, AZ, platform, tenancy, term and payment are all in the body.
  • Injection: the scheduled body is plain text via text/template (internal/email/template_renderers.go:121); no HTML part, no header fields fed from recommendation data.
  • Existing emails: only scheduledPurchaseTemplate changed; with TotalCommitment == 0 its output is byte-identical to before (blank line preserved). New-recommendations, confirmation and failed templates are untouched; buildNotificationData has one caller (notifications.go:105).
  • S13: internal/config/plan_veto_window.go:16 refuses NotificationDaysBefore < 1. Resume (internal/api/handler_purchases.go:231-268) reads the row and plan before the CAS and returns 409 without any change for a due rec-bearing row; empty rows and future-dated rows are untouched. GetPurchasePlan/GetExecutionByID return ErrNotFound rather than nil, so no nil plan path.
  • Inert: no production caller of AttachResolvedRecommendations, planstep.Resolve or RequireVetoWindow outside the resume handler; kill switch read fails closed (notifications.go:27-31), caps untouched.

Local verification (throwaway local PostgreSQL 17.11 cluster on 127.0.0.1, git archive of the exact head, GOTOOLCHAIN=go1.26.9, under the suite lock; testhelpers container helper overridden to the local cluster in the scratch copy only, never committed). Rows are fixtures.

  • go build ./..., go vet api/config/purchase/email: OK. go test api/config/purchase/email: 4641 passed.
  • go test -tags integration internal/api, internal/config, internal/purchase: ok (92s, 111s, 29s). TestNotifiedRow_* 4/4 PASS verbose.
  • Fails-before: with handler_purchases.go from origin/main and the new tests, unit TestResumePlannedPurchase_RefusesWithoutVetoWindow FAILS and integration TestNotifiedRow_ResumeRefusedWithoutVetoWindow FAILS (got nil, status pending instead of paused).
  • Mutants (19, one run): killed 18: veto <0, veto <=1, sort.Strings removed (expected aws,azure,gcp; got gcp,aws,azure), dedupe removed, resume precheck removed, empty-rec exemption dropped, date check dropped, commitment=upfront, recurring months, units, cap not set, platform/tenancy swap, AZ dropped, monthly not shown, three template guards.
  • git-secrets --register-aws --scan with BSD grep and GNU grep: exit 0 both.
  • CI at this SHA: all checks SUCCESS (incl. CodeRabbit status), mergeStateStatus CLEAN.

Non-blocking follow-ups

  • Surviving mutant: removing the post-CAS backstop in pushPastScheduledDate (handler_purchases.go:285-287) passes; it only fires if the plan's NotificationDaysBefore drops to 0 between the precheck and the CAS. When it fires, the row is already pending with a past date and the client gets 409; the scheduler refuses rec-bearing pending rows, so no buy, but B3's re-notify path should not promote such a row to notified without re-dating it.
  • notifications.go:339 recomputes recurring as MonthlyCost*Term*12 without the Term > 0 guard that TotalCommitment() has; a negative Term would make the summary's upfront + recurring disagree with the total. summary.Commitment - rec.UpfrontCost would reuse the one formula.
  • $ is hardcoded (as in the existing templates); per-row cents can differ by rounding from the summed total.

@cristim
cristim merged commit c4f7ec9 into main Oct 13, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/xl Multi-week / refactor impact/many Affects most users priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/feat New capability urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant