Skip to content

Add ACP Closure Service (Phase 1: subscription end-date closure) - #1330

Merged
rksk merged 26 commits into
wso2-open-operations:mainfrom
Ananth-Abi:feature/acp-closure-service
Aug 6, 2026
Merged

rksk merged 26 commits into
wso2-open-operations:mainfrom
Ananth-Abi:feature/acp-closure-service

Conversation

@Ananth-Abi

@Ananth-Abi Ananth-Abi commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements Phase 1 of the ACP (Account Closure Process) migration from
ServiceNow — a Go-based scheduled component that replaces the legacy
ServiceNow subscription end-date closure automation.

Scope: subscription end-date closure only (90/60/30/15/7/0-day notice
cascade, then suspend). Invoice- and compliance-based closure (Phase 2)
are explicitly out of scope — see docs/legacy-servicenow-reference/
for the original ServiceNow Script Include source this replaces.

Closes wso2-enterprise/digiops-cs#2524

Architecture

New top-level Go module (acp-closure-service/), deployed as a Choreo
Scheduled Task (run-to-completion CLI, not a long-running process).
Calls csm-integration-service (not entity-service directly) via M2M
client-credentials auth — see CLAUDE.md for the full architecture
rationale.

Package structure:

  • internal/closure — pure decision logic (days-remaining, cascading
    notice-window thresholds)
  • internal/recipients — three-tier customer contact fallback
    (business-contact role → primary contact → notify Account Manager)
  • internal/entity — API client for csm-integration-service
  • internal/suspensionstate — translates between the real
    suspensionProcessState JSON blob and internal types, preserving
    invoice/compliance sections untouched
  • internal/notify — notification interface (currently logs only —
    real email-sending is pending WSO2's message-queue design)
  • internal/sweep — orchestrates the full daily sweep, with a dry-run
    toggle (DRY_RUN env var, defaults to true) via dependency injection

Testing

  • 34 unit/integration tests, all passing
  • Verified against real staging data via direct Postman testing
    throughout development
  • Broad dry-run tested locally against all 53 real open projects in
    staging (1 known, pre-existing data issue, unrelated to this code)
  • Deployed and manually executed successfully in Choreo (Azure-Staging,
    POC project) against the same real data, confirming identical behavior
    to local testing

Notes for reviewers

  • DRY_RUN defaults to true — no real writes or emails happen unless
    explicitly set to false
  • See CLAUDE.md for detailed architecture decisions and confirmed cases
    where live API behavior diverged from documented/coded values

Summary by CodeRabbit

  • New Features

    • Added an ACP Closure Service to evaluate open projects, send scheduled notices, and suspend eligible projects.
    • Supports customer, internal, and Account Manager notification paths with contact fallbacks.
    • Added dry-run mode and optional single-project execution for safer testing.
    • Added authenticated integration, configuration templates, and build/test commands.
  • Documentation

    • Added setup, configuration, architecture, testing, and operational guidance.
    • Updated repository documentation with the new service location.
  • Tests

    • Added coverage for closure decisions, notifications, pagination, suspension safeguards, and error handling.

Abinash Ananth added 19 commits July 24, 2026 14:23
Covers go.mod, internal/closure/decide.go and its tests, and the
StubEndDate placeholder pending entity-service's endDate field
(now confirmed live, not yet wired in).
…k chain)

Three-tier fallback: business-contact role, then primary contact,
then Account Manager nudge. Includes businessContactRole and
StubOwnerEmail placeholders pending API-team confirmation.
OAuth2 client-credentials client against csm-integration-service, with
RequiredScopes documenting the seven confirmed scope strings and
correlation-ID forwarding. Includes SearchProjects, SearchAccountContacts,
SearchProjectContacts, UpdateProject, GetAccount. Drops the
x-user-id-token pass-through machinery, unused by this headless job.

golang.org/x/oauth2 pinned to v0.27.0 to match existing repo convention.
Translates between suspensionProcessState's real JSON blob shape
(based_on_subscription_end_date/based_on_due_invoices/based_on_compliance)
and closure.NoticeWindow. WithSubscriptionEndDateState preserves
based_on_due_invoices and based_on_compliance untouched, verified by a
dedicated test using realistic multi-field data.

Also corrects internal/entity's stale UpdateProject 401 comment to
reflect confirmed M2M-write success, and removes the no-longer-needed
internal/auth plan (superseded by entity.NewClient's real OAuth2
implementation).
…ier)

Notifier interface with a logging implementation, since real email-sending
is not yet built on the entity-service side (deferred pending message-
queue design). Future real implementation will satisfy the same interface.
…rite-back orchestration)

processProject is the core seam: fetches decision from closure.Decide,
resolves recipients via the three-tier fallback, sends notifications
through the injected notifier, and writes back suspensionProcessState
via the suspensionstate package.

Covers: suspend guard (closureState != Suspended before acting), day-0
email-then-suspend ordering with stop-on-failure (both fresh-success and
retry-after-partial-failure paths), audience routing (90/60/30 internal-
only vs 15/7/0 both), and notify-failure blocking the state write so
closure.Decide naturally re-fires on the next run.

9 passing tests. Dry-run is an injection choice (projectUpdater
interface), not a boolean threaded through core logic.
Loops through all open projects via SearchProjects, paginating at 100
per page (matching entity-service's cap), calling processProject for
each. Two-tier failure handling: a single project's failure is logged
and appended to Result.Failures without stopping the sweep; a
page-fetch failure itself is fatal and returns a non-nil error, since
there's no way to know what projects exist beyond an unreachable page.

4 new tests: single-page evaluation, multi-page offset progression
(also validates the exact request-body shape), one-project-failure
doesn't block the rest, and page-fetch-failure is fatal. 13 tests total
across processProject + Run.
…ding

cmd/acp-closure/main.go wires config loading (env-vars-only,
CSM_INTEGRATION_* naming), the DRY_RUN safety-first toggle (defaults
true unless explicitly set to false), a per-run correlation ID, and the
real-vs-dry-run projectUpdater choice, then calls sweep.Run and logs
the final Result.

internal/sweep/dryrun.go: DryRunProjectUpdater, a logging stand-in for
real writes.

.env.example documents required config without real secrets.
Module-local .gitignore protects .env and build artifacts.

Also includes the earlier recipients.go comment update reflecting AM
owner-email resolution now being actively worked on by the API team.

Component is now runnable end-to-end via go run ./cmd/acp-closure.
the ACP closure service: ProjectView now includes a nested Account
{id, name} reference on /projects/search results (previously only
available on the single-project detail endpoint), and Account person
references (technicalOwner/accountManager/renewalAccountManager) now
carry an email field via a new PersonRef type, replacing the prior
owner-only, email-less EntityRef shape.
The nested Account *projectAccountRef shape was already correctly
implemented; only a doc comment incorrectly claimed SearchProjects never
carries an account field. Corrected to reflect confirmed reality (both
GetProject and SearchProjects include account.id/account.name) —
verified via direct Postman testing against real staging data.
Replaces StubOwnerEmail entirely with a real implementation:
recipients.PersonRef + AccountManagerEmail (pure function, treats
missing AM or missing email as legitimate absence, not an error), and
sweep.resolveAccountManagerEmail (I/O half, calls GetAccount and parses
the real accountManager.email shape).

6 new tests: 3 pure-package (populated, nil PersonRef, PersonRef-with-nil-
email), 3 sweep-integration (real Postman response shape confirming the
recipient resolves to a real email, plus the null/missing case sending
with an empty recipient, no error).

Empirically confirmed via direct Postman testing against a real staging
account (technicalOwner/accountManager/renewalAccountManager all
populated with real id/name/email).

Resolves 4 of the original 5 open dependencies: endDate, M2M auth,
suspensionProcessState shape, and AM owner-email. Remaining: the
business-contact role literal string, and the real email-sending
mechanism (pending WSO2's message-queue design).
Adds an optional TEST_PROJECT_ID env var that, when set, restricts the
sweep to a single project (via a new GetProject client method) instead
of searching all open projects — used for safely verifying behavior
against one dedicated test project before running against the full
staging dataset.

Documented in .env.example.
Empirically confirmed via Postman that the live staging environment
enforces a maximum page size of 50, not 100 as entity-service's own
maxLimit constant claims — limit:50 returns 200, limit:51 returns 400.

This is the fourth confirmed case this session of live API behavior
diverging from entity-service's documented/coded values (alongside
endDate, M2M auth, and suspensionProcessState shape) — caught by direct
testing rather than trusting code alone.

Updated pageSize in run.go and all corresponding test fixtures/assertions
that referenced the old value. All 30 tests pass.
Notice gained a ResolvedVia field (recipients.ResolvedVia), logged as
resolvedVia on every notice line. Reflects the real fallback tier used
for customer/AM-nudge notices; left at zero value for internal notices,
which never go through the fallback chain.

2 new notify tests verify the log line carries the correct structured
attribute.
…same recipient

When both the regular internal notice and the AM-nudge (configure a
business contact) would go to the same real recipient, only the
am_nudge notice fires now — the Account Manager gets one email, not
two, per confirmed product decision.

New shouldSuppressInternalNotice(internalRecipient, nudgeRecipient string)
bool suppresses only when both are equal AND non-empty — an
unresolved/empty AM email is not a real duplicate-email risk, and
suppressing there would only hide useful debug signal without any
real-world benefit.

4 new tests, including the pre-existing empty-recipient edge case
re-verified to still pass unmodified. 34 tests total across the module.
Makefile: vet/test/build/clean targets, mirroring csm-integration-
service's shape (no setup target, since this module isn't wired into
the shared pre-push hook this round).

README.md: quick start, config table, project structure, confirmed
live-vs-documented discrepancies, and remaining open dependencies.
Notes that 'make test' should be run manually before pushing.

CLAUDE.md: deeper architecture/decision record — the pure/IO package
split, why csm-integration-service rather than entity-service directly,
the dry-run-by-injection design, notice-suppression logic, and
suspensionProcessState's real confirmed shape.

Verified make test/build/clean all actually work as documented.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Added a Go ACP Closure Service. It authenticates with the CSM Integration Service, evaluates subscription end-date notice windows, resolves recipients, records suspension state, supports dry-run execution, and includes tests, configuration, and documentation.

Changes

ACP closure workflow

Layer / File(s) Summary
Entity service client
integrations/acp-closure-service/internal/apierror/*, integrations/acp-closure-service/internal/entity/*
Adds OAuth2 requests, correlation IDs, typed upstream errors, redirect blocking, and project, account, and contact operations.
Closure decisions, recipients, and state
integrations/acp-closure-service/internal/closure/*, integrations/acp-closure-service/internal/recipients/*, integrations/acp-closure-service/internal/suspensionstate/*, integrations/acp-closure-service/internal/notify/*
Adds notice-window decisions, recipient fallback rules, Account Manager resolution, notification payloads, logging behavior, and suspension-state JSON preservation.
Sweep orchestration
integrations/acp-closure-service/internal/sweep/*
Adds scoped and paginated sweeps, notification routing, state updates, suspension guards, dry-run updates, and comprehensive tests.
CLI wiring and operations
integrations/acp-closure-service/cmd/*, integrations/acp-closure-service/.env.example, integrations/acp-closure-service/Makefile, integrations/acp-closure-service/README.md, integrations/acp-closure-service/CLAUDE.md, README.md, .gitignore
Adds environment parsing, OAuth wiring, run IDs, exit handling, build targets, configuration, repository placement, ignore rules, and service documentation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ACPCLI
  participant SweepRun
  participant EntityClient
  participant ClosureRules
  participant LoggingNotifier
  participant ProjectUpdater
  ACPCLI->>SweepRun: Run one closure sweep
  SweepRun->>EntityClient: Fetch projects or scoped project
  SweepRun->>ClosureRules: Evaluate notice window
  SweepRun->>EntityClient: Fetch contacts and account data
  SweepRun->>LoggingNotifier: Send closure notice
  SweepRun->>ProjectUpdater: Persist notice state or suspension
Loading

Possibly related PRs

Suggested labels: Area/Backend, Type/New Feature, Op/CSM Integration Service

Suggested reviewers: cloby99

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the implementation and testing, but it omits most sections required by the repository template. Add the missing template sections, including goals, user stories, release note, documentation, security checks, test environment, migrations, and related PRs.
Docstring Coverage ⚠️ Warning Docstring coverage is 74.80% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the addition of the ACP Closure Service and its Phase 1 subscription end-date closure scope.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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

Actionable comments posted: 6

🧹 Nitpick comments (3)
acp-closure-service/internal/entity/client_test.go (1)

75-81: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Assert bearer authentication in TestDoSuccess.

The handler returns success when the Authorization header is absent. A regression that removes OAuth2 authentication from resource requests would pass this test. Require Authorization: Bearer test-token. The OAuth2 transport is responsible for adding this header. (pkg.go.dev)

Proposed fix
 if r.Method != http.MethodPost || r.URL.Path != "/projects/search" {
 	t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
 }
+if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
+	t.Errorf("Authorization = %q, want %q", got, "Bearer test-token")
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@acp-closure-service/internal/entity/client_test.go` around lines 75 - 81,
Update the HTTP handler in TestDoSuccess to assert that the request
Authorization header exactly equals “Bearer test-token”, alongside the existing
method and path checks. Keep the success response unchanged so the test verifies
the OAuth2 transport adds the required bearer token.
acp-closure-service/internal/closure/decide.go (1)

122-136: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Simplify daysBetween using math.Ceil.

daysBetween hand-rolls a ceiling-toward-positive-infinity calculation with a branch on sign. This is correct, but it duplicates what math.Ceil already does for both positive and negative inputs. Use math.Ceil to remove the branch and the manual float-to-int comparison.

♻️ Proposed simplification
+import "math"
+
 func daysBetween(now, endDate time.Time) int {
-	d := endDate.Sub(now)
-	days := d.Hours() / 24
-	if days != float64(int(days)) {
-		if days > 0 {
-			days = float64(int(days)) + 1
-		} else {
-			days = float64(int(days))
-		}
-	}
-	return int(days)
+	d := endDate.Sub(now)
+	return int(math.Ceil(d.Hours() / 24))
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@acp-closure-service/internal/closure/decide.go` around lines 122 - 136,
Update daysBetween to return the integer conversion of math.Ceil applied to the
duration’s day value, removing the manual float comparison and sign branch while
preserving ceiling-toward-positive-infinity behavior for positive and negative
durations.
acp-closure-service/internal/suspensionstate/suspensionstate_test.go (1)

27-83: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a round-trip test across all six notice windows.

eventTypeToWindow and windowToEventType in suspensionstate.go are two hand-maintained maps. A table-driven test that calls WithSubscriptionEndDateState for each of the six closure.NoticeWindow constants and feeds the result back through LastNoticeWindow would catch any future drift between the two maps before it reaches production.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@acp-closure-service/internal/suspensionstate/suspensionstate_test.go` around
lines 27 - 83, Extend TestLastNoticeWindow with a table-driven round-trip case
covering all six closure.NoticeWindow constants: build each state using
WithSubscriptionEndDateState, pass its serialized result to LastNoticeWindow,
and assert the original window is returned without error. Use the existing
event-type mapping behavior and ensure every notice-window constant is included.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@acp-closure-service/cmd/acp-closure/main.go`:
- Around line 84-86: Update the result handling in main so that after logging
entries from result.Failures, it exits with status 1 when the collection is
non-empty. Preserve the existing successful return path when no project failures
occur.
- Around line 142-144: Update the environment-loading logic around os.Setenv in
loadDotEnv to use os.LookupEnv, setting values from .env only when the variable
is unset. Preserve explicitly configured empty environment variables so mustEnv
can handle them rather than replacing them.

In `@acp-closure-service/go.mod`:
- Line 3: Update the go directive in go.mod from Go 1.26.4 to 1.26.5, and align
the project’s build images with Go 1.26.5 so all builds enforce the same minimum
patched version.

In `@acp-closure-service/internal/entity/client.go`:
- Around line 104-107: Update the HTTP client setup around tokenCtx and
httpClient so authenticated API requests reject redirects before the OAuth2
transport follows them. Configure the http.Client used by cc.Client with a
CheckRedirect policy that returns http.ErrUseLastResponse, preserving the
existing timeout settings.

In `@acp-closure-service/internal/suspensionstate/suspensionstate.go`:
- Around line 101-118: Update WithSubscriptionEndDateState to validate the
second return value from windowToEventType before constructing section. If
window has no known mapping, return a descriptive error instead of persisting an
empty event_type; preserve the existing serialization flow for valid windows.

In `@acp-closure-service/internal/sweep/sweep.go`:
- Around line 183-211: Update fetchContacts so that after parsing project
contacts, it checks proj.accountID() and returns the project contacts with no
account contacts when the account ID is empty, avoiding SearchAccountContacts;
retain the existing account-contact search and error handling for projects with
a non-empty account ID.

---

Nitpick comments:
In `@acp-closure-service/internal/closure/decide.go`:
- Around line 122-136: Update daysBetween to return the integer conversion of
math.Ceil applied to the duration’s day value, removing the manual float
comparison and sign branch while preserving ceiling-toward-positive-infinity
behavior for positive and negative durations.

In `@acp-closure-service/internal/entity/client_test.go`:
- Around line 75-81: Update the HTTP handler in TestDoSuccess to assert that the
request Authorization header exactly equals “Bearer test-token”, alongside the
existing method and path checks. Keep the success response unchanged so the test
verifies the OAuth2 transport adds the required bearer token.

In `@acp-closure-service/internal/suspensionstate/suspensionstate_test.go`:
- Around line 27-83: Extend TestLastNoticeWindow with a table-driven round-trip
case covering all six closure.NoticeWindow constants: build each state using
WithSubscriptionEndDateState, pass its serialized result to LastNoticeWindow,
and assert the original window is returned without error. Use the existing
event-type mapping behavior and ensure every notice-window constant is included.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 50502ea4-6bff-4056-be5c-048903d68dcf

📥 Commits

Reviewing files that changed from the base of the PR and between 2e3d985 and c437827.

⛔ Files ignored due to path filters (1)
  • acp-closure-service/go.sum is excluded by !**/*.sum
📒 Files selected for processing (27)
  • acp-closure-service/.env.example
  • acp-closure-service/.gitignore
  • acp-closure-service/CLAUDE.md
  • acp-closure-service/Makefile
  • acp-closure-service/README.md
  • acp-closure-service/cmd/acp-closure/main.go
  • acp-closure-service/go.mod
  • acp-closure-service/internal/apierror/apierror.go
  • acp-closure-service/internal/closure/decide.go
  • acp-closure-service/internal/closure/decide_test.go
  • acp-closure-service/internal/entity/client.go
  • acp-closure-service/internal/entity/client_test.go
  • acp-closure-service/internal/entity/entity.go
  • acp-closure-service/internal/notify/notify.go
  • acp-closure-service/internal/notify/notify_test.go
  • acp-closure-service/internal/recipients/recipients.go
  • acp-closure-service/internal/recipients/recipients_test.go
  • acp-closure-service/internal/suspensionstate/suspensionstate.go
  • acp-closure-service/internal/suspensionstate/suspensionstate_test.go
  • acp-closure-service/internal/sweep/dryrun.go
  • acp-closure-service/internal/sweep/helpers_test.go
  • acp-closure-service/internal/sweep/run.go
  • acp-closure-service/internal/sweep/run_test.go
  • acp-closure-service/internal/sweep/sweep.go
  • acp-closure-service/internal/sweep/sweep_test.go
  • acp-closure-service/internal/sweep/types.go
  • acp-closure-service/internal/sweep/types_test.go

Comment thread integrations/acp-closure-service/cmd/acp-closure/main.go
Comment thread acp-closure-service/cmd/acp-closure/main.go Outdated
Comment thread acp-closure-service/go.mod Outdated
Comment thread integrations/acp-closure-service/internal/entity/client.go
Comment thread integrations/acp-closure-service/internal/sweep/sweep.go

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

Four inline comments below on the sweep/orchestration layer. Two of them (the suspend guard and the empty-account-ID path) I'd treat as merge blockers; the other two are hardening for a job that writes to a shared, live data source on a cron.

Separately, and not inline because it isn't tied to one line: notify.LoggingNotifier is the only Notifier implementation, but recordNoticeSent writes actionSendEmailNotification: "SUCCESSFUL" unconditionally. With DRY_RUN=false that means the job records notices as sent, advances based_on_subscription_end_date.event_type, and suspends past-due projects, while no email was ever sent. DRY_RUN defaulting to true is good, but it is one config value away from that outcome. Consider making the absence of a real sender structurally block writes (fail fast in main, or write IGNORED rather than SUCCESSFUL when the notifier does not actually send) rather than relying on the default.

The pure/I-O split, the DI-based dry-run, and the byte-for-byte preservation of the sibling suspensionProcessState sections are all well done.

Comment thread acp-closure-service/internal/sweep/sweep.go Outdated
Comment thread integrations/acp-closure-service/internal/sweep/sweep.go
Comment thread integrations/acp-closure-service/cmd/acp-closure/main.go
Comment thread integrations/acp-closure-service/internal/sweep/run.go

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

One more, on placement: this shouldn't be a new top-level directory. Detail inline on go.mod, since the module path has to change with the move and that ripples through every import in the PR — much cheaper to do now than after merge.

Comment thread acp-closure-service/go.mod Outdated
Comment thread integrations/acp-closure-service/.env.example
Abinash Ananth added 3 commits August 3, 2026 14:30
…hmika, and CodeRabbit

- Move acp-closure-service under integrations/ to match repo convention
- Guard fetchContacts against projects with no linked account
- Exit non-zero on partial sweep failures
- Bound the pagination loop by Total, not just HasMore
- Record IGNORED instead of SUCCESSFUL when the notifier doesn't deliver
- Fix suspend guard to check endDateClosureState, not closureState
- Make CSM_INTEGRATION_SCOPES a required env var instead of hardcoded
- loadDotEnv: use LookupEnv to preserve explicitly-set empty env vars
- Reject 3xx redirects in entity.Client to prevent an Authorization leak
- suspensionstate: error on an unmapped NoticeWindow instead of writing
  event_type: ""

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

Actionable comments posted: 3

🧹 Nitpick comments (5)
integrations/acp-closure-service/internal/closure/decide_test.go (1)

73-115: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a same-window re-run case.

The catch-up table covers advancing to a narrower window. It does not cover the daily-idempotency case: lastNoticeWindow = &w60 with 55 days remaining must report Fires = false. That case protects against a repeated notice on every sweep inside the same window.

💚 Proposed test case
 	w90 := NoticeWindow90
+	w60 := NoticeWindow60
@@
 		{
 			name:             "already notified at 90, jumped to 10 days remaining: catches up to 15, not re-firing 90",
 			daysRemaining:    10,
 			lastNoticeWindow: &w90,
 			wantWindow:       NoticeWindow15,
 			wantFires:        true,
 		},
+		{
+			name:             "already notified at 60, still inside the 60 window: nothing re-fires",
+			daysRemaining:    55,
+			lastNoticeWindow: &w60,
+			wantFires:        false,
+		},
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@integrations/acp-closure-service/internal/closure/decide_test.go` around
lines 73 - 115, Add a table-driven case to
TestDecide_MissedRunCatchesUpToNearestWindow with lastNoticeWindow set to
NoticeWindow60 and 55 days remaining, asserting Fires is false. Keep the
existing catch-up cases unchanged and ensure the same-window sweep does not
produce a notice.
integrations/acp-closure-service/internal/closure/decide.go (1)

112-120: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the unused exported StubEndDate API.

StubEndDate exists only as a comment and declaration, ignores projectID, and returns the zero time.Time. Delete the function or keep any future end-date seam unavailable until entity-service exposes endDate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@integrations/acp-closure-service/internal/closure/decide.go` around lines 112
- 120, Remove the exported StubEndDate function and its associated placeholder
comments from the closure package. Do not add a replacement seam or retain the
unused projectID parameter and zero time.Time return.
integrations/acp-closure-service/internal/recipients/recipients.go (1)

74-94: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider skipping contacts that have no email.

ResolveCustomerContact selects the first contact by role or primary flag only. It does not check Email. A business contact with an empty Email resolves as tier 1 and blocks the primary-contact tier. The caller then builds a customer notice with an empty Recipient, and no AM nudge fires.

The package already models missing contact data as normal for this data source (see the PersonRef.Email note at Line 98). If an empty contact email is possible upstream, treat it the same as no contact at that tier.

♻️ Proposed tier selection that requires an email
 	for _, c := range projectContacts {
-		if hasBusinessContactRole(c) {
+		if hasBusinessContactRole(c) && c.Email != "" {
 			return Resolution{
 				CustomerContact: &Contact{Name: c.Name, Email: c.Email},
 				ResolvedVia:     ResolvedViaBusinessContact,
 			}
 		}
 	}
 
 	for _, c := range accountContacts {
-		if c.IsPrimary {
+		if c.IsPrimary && c.Email != "" {
 			return Resolution{
 				CustomerContact: &Contact{Name: c.Name, Email: c.Email},
 				ResolvedVia:     ResolvedViaPrimaryContact,
 			}
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@integrations/acp-closure-service/internal/recipients/recipients.go` around
lines 74 - 94, Update ResolveCustomerContact so both the business-contact loop
and primary account-contact loop skip contacts whose Email is empty before
selecting them. Preserve the existing tier priority, resolution metadata, and
NeedsAMNudge fallback when no eligible contact has an email.
integrations/acp-closure-service/internal/sweep/run_test.go (1)

59-94: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the page size as well as the offset.

This test already decodes searchProjectsRequest, but it checks Pagination.Offset only. No test asserts Pagination.Limit == 50. The comment in run.go (Lines 27-31) records that limit: 51 returns a 400 against the live API, so the exact page size is a load-bearing constant. Add the assertion here to protect it.

♻️ Proposed assertion
 			gotOffsets = append(gotOffsets, req.Pagination.Offset)
+			if req.Pagination.Limit != 50 {
+				t.Errorf("Pagination.Limit = %d, want 50", req.Pagination.Limit)
+			}

Based on learnings, this follows the coding guideline "Run must paginate /projects/search with a page size of exactly 50".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@integrations/acp-closure-service/internal/sweep/run_test.go` around lines 59
- 94, Update TestRun_MultiPagePaginatesUntilHasMoreFalse to assert
req.Pagination.Limit equals 50 for each decoded searchProjectsRequest, alongside
the existing offset checks. Preserve the current pagination and result
assertions.

Source: Coding guidelines

integrations/acp-closure-service/internal/sweep/sweep_test.go (1)

86-99: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Add a processProject-level preservation test for the sibling suspension dimensions.

integrations/acp-closure-service/internal/sweep/sweep_test.go only decodes based_on_subscription_end_date in update-body assertions. No test gives processProject a SuspensionProcessState with populated based_on_due_invoices and based_on_compliance, then asserts those keys are unchanged in updater.calls[0].body. Add one case with all three dimensions, fire the 90-day window, and compare the two untouched subtrees against the input so regression that drops sibling Phase 2 data does not pass.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@integrations/acp-closure-service/internal/sweep/sweep_test.go` around lines
86 - 99, Add a processProject-level test in sweep_test.go that supplies
SuspensionProcessState with populated based_on_subscription_end_date,
based_on_due_invoices, and based_on_compliance values, triggers the 90-day
window, then decodes updater.calls[0].body and asserts the due-invoices and
compliance subtrees match the input unchanged while retaining the existing
90_days_notice assertion.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@integrations/acp-closure-service/internal/notify/notify.go`:
- Around line 70-79: Update LoggingNotifier.Send to stop logging
notice.Recipient at Info level, while preserving the existing ResolvedVia field
and all other required notice metadata. Redact or omit the recipient value so
customer and Account Manager contact details are never written to logs.

In `@integrations/acp-closure-service/internal/sweep/sweep.go`:
- Around line 136-142: Validate that the contact selected by
ResolveCustomerContact has a non-empty email before sending the KindCustomer
notification in the sweep flow. If the CustomerContact email is empty, reject
that resolution and continue the existing fallback chain so it reaches
NeedsAMNudge instead of calling ntf.Send with an empty Recipient.

In `@integrations/acp-closure-service/README.md`:
- Around line 30-39: Update both Go version references in the README—under the
runtime details and prerequisites—to require Go 1.26.5+ instead of Go 1.26+,
matching the version declared in go.mod.

---

Nitpick comments:
In `@integrations/acp-closure-service/internal/closure/decide_test.go`:
- Around line 73-115: Add a table-driven case to
TestDecide_MissedRunCatchesUpToNearestWindow with lastNoticeWindow set to
NoticeWindow60 and 55 days remaining, asserting Fires is false. Keep the
existing catch-up cases unchanged and ensure the same-window sweep does not
produce a notice.

In `@integrations/acp-closure-service/internal/closure/decide.go`:
- Around line 112-120: Remove the exported StubEndDate function and its
associated placeholder comments from the closure package. Do not add a
replacement seam or retain the unused projectID parameter and zero time.Time
return.

In `@integrations/acp-closure-service/internal/recipients/recipients.go`:
- Around line 74-94: Update ResolveCustomerContact so both the business-contact
loop and primary account-contact loop skip contacts whose Email is empty before
selecting them. Preserve the existing tier priority, resolution metadata, and
NeedsAMNudge fallback when no eligible contact has an email.

In `@integrations/acp-closure-service/internal/sweep/run_test.go`:
- Around line 59-94: Update TestRun_MultiPagePaginatesUntilHasMoreFalse to
assert req.Pagination.Limit equals 50 for each decoded searchProjectsRequest,
alongside the existing offset checks. Preserve the current pagination and result
assertions.

In `@integrations/acp-closure-service/internal/sweep/sweep_test.go`:
- Around line 86-99: Add a processProject-level test in sweep_test.go that
supplies SuspensionProcessState with populated based_on_subscription_end_date,
based_on_due_invoices, and based_on_compliance values, triggers the 90-day
window, then decodes updater.calls[0].body and asserts the due-invoices and
compliance subtrees match the input unchanged while retaining the existing
90_days_notice assertion.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 16c3e829-297f-4c9e-9c9e-ff9927afd111

📥 Commits

Reviewing files that changed from the base of the PR and between c437827 and a05d321.

⛔ Files ignored due to path filters (1)
  • integrations/acp-closure-service/go.sum is excluded by !**/*.sum
📒 Files selected for processing (30)
  • .gitignore
  • README.md
  • integrations/acp-closure-service/.env.example
  • integrations/acp-closure-service/.gitignore
  • integrations/acp-closure-service/CLAUDE.md
  • integrations/acp-closure-service/Makefile
  • integrations/acp-closure-service/README.md
  • integrations/acp-closure-service/cmd/acp-closure/main.go
  • integrations/acp-closure-service/cmd/acp-closure/main_test.go
  • integrations/acp-closure-service/go.mod
  • integrations/acp-closure-service/internal/apierror/apierror.go
  • integrations/acp-closure-service/internal/closure/decide.go
  • integrations/acp-closure-service/internal/closure/decide_test.go
  • integrations/acp-closure-service/internal/entity/client.go
  • integrations/acp-closure-service/internal/entity/client_test.go
  • integrations/acp-closure-service/internal/entity/entity.go
  • integrations/acp-closure-service/internal/notify/notify.go
  • integrations/acp-closure-service/internal/notify/notify_test.go
  • integrations/acp-closure-service/internal/recipients/recipients.go
  • integrations/acp-closure-service/internal/recipients/recipients_test.go
  • integrations/acp-closure-service/internal/suspensionstate/suspensionstate.go
  • integrations/acp-closure-service/internal/suspensionstate/suspensionstate_test.go
  • integrations/acp-closure-service/internal/sweep/dryrun.go
  • integrations/acp-closure-service/internal/sweep/helpers_test.go
  • integrations/acp-closure-service/internal/sweep/run.go
  • integrations/acp-closure-service/internal/sweep/run_test.go
  • integrations/acp-closure-service/internal/sweep/sweep.go
  • integrations/acp-closure-service/internal/sweep/sweep_test.go
  • integrations/acp-closure-service/internal/sweep/types.go
  • integrations/acp-closure-service/internal/sweep/types_test.go

Comment thread integrations/acp-closure-service/internal/notify/notify.go
Comment thread integrations/acp-closure-service/internal/sweep/sweep.go
Comment thread integrations/acp-closure-service/README.md Outdated
Abinash Ananth added 4 commits August 5, 2026 13:48
- Don't log the raw notice recipient (PII) in LoggingNotifier.Send;
  ResolvedVia already gives the observability that's actually needed
- ResolveCustomerContact now requires a non-empty email before
  accepting a business/primary contact match, so a contact on file
  with no email falls through to the AM nudge instead of resolving
  to an empty Recipient
- Fix README's stated Go version (1.26+ -> 1.26.5+) to match go.mod
LoggingNotifier is currently the only 'sender' that exists (real email
sending isn't implemented yet), so the recipient in the log is the only
way to verify from logs alone who a notice would have gone to during
this Phase 1 rollout. Worth revisiting once real sending exists.
@rksk

rksk commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai resume

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Reviews resumed.

@rksk
rksk requested a review from Rashmika998 August 5, 2026 17:52

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@integrations/acp-closure-service/internal/notify/notify.go`:
- Line 75: Remove the "recipient", notice.Recipient structured-log field from
the notification logging call, while retaining kind, window, projectID, and
resolvedVia. Add a regression test for the relevant notification logging path
that verifies recipient data is absent from the emitted log attributes.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 70b2ac20-1c0e-49ae-9b33-1f9845f9d537

📥 Commits

Reviewing files that changed from the base of the PR and between 8b39f5c and 27cb380.

📒 Files selected for processing (2)
  • integrations/acp-closure-service/internal/notify/notify.go
  • integrations/acp-closure-service/internal/notify/notify_test.go
💤 Files with no reviewable changes (1)
  • integrations/acp-closure-service/internal/notify/notify_test.go

Comment thread integrations/acp-closure-service/internal/notify/notify.go

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

LGTM

@rksk
rksk merged commit f2ac469 into wso2-open-operations:main Aug 6, 2026
1 check passed
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.

3 participants