Skip to content

Commit 72606a7

Browse files
authored
feat(current-account): add Account Control Operations (UpCR and CoCR) (#424)
* feat(current-account): add proto definitions for UpdateCurrentAccount and ControlCurrentAccount Implement BIAN Control Record operations for account lifecycle management: - UpdateCurrentAccountRequest/Response (UpCR) for modifying overdraft settings and status - ControlCurrentAccountRequest/Response (CoCR) for state transitions (freeze/unfreeze/close) - ControlAction enum with FREEZE, UNFREEZE, CLOSE actions - Validation rules: account_id required, overdraft_rate range 0-100 - HTTP annotations: PUT /v1/current-accounts/{account_id} and POST /v1/current-accounts/{account_id}/control * feat: implement account state machine with strict validation Add robust state machine enforcement to CurrentAccount domain model: - Freeze(reason) now requires 10+ char reason, only from ACTIVE status - Unfreeze() transitions FROZEN -> ACTIVE and clears freeze reason - Close() validates zero balance (lien check is service-layer concern) - UpdateOverdraftSettings() validates rate >= 0 before delegation - StatusChange struct and statusHistory for audit trail tracking - CLOSED is terminal - no transitions permitted from closed state State transition diagram documented in code: ACTIVE <-> FROZEN -> CLOSED (terminal) Includes 30+ comprehensive unit tests covering: - Valid/invalid state transitions for all methods - Freeze reason validation (empty, short, exact 10 chars) - Close with non-zero/negative balance validation - Status history immutability and audit trail - Builder methods for freeze reason and status history * feat(current-account): implement UpdateCurrentAccount and ControlCurrentAccount service handlers Add BIAN Control Record operations for account lifecycle management: UpdateCurrentAccount (UpCR): - Updates overdraft settings (limit, rate, enabled status) - Uses optimistic locking to prevent concurrent modification conflicts - Returns InvalidArgument for currency mismatch or invalid rates - Returns FailedPrecondition if account is closed ControlCurrentAccount (CoCR): - Implements FREEZE action with required reason (min 10 characters) - Implements UNFREEZE to restore frozen accounts to active - Implements CLOSE with validation for zero balance and no active liens - All transitions use domain layer state machine for consistency - Returns Aborted on version conflicts for retry Also adds CountActiveByAccountID to LienRepository to support lien checking during account close operations. Note: Kafka event emission and webhook notifications are documented as TODOs for future implementation (requires Kafka producer integration). * feat: add status_history audit trail for BIAN Control Record operations Add database migration and repository support for tracking account status changes (ACTIVE -> FROZEN -> CLOSED) with full audit trail in JSONB column. Changes: - Add migration for status_history JSONB column and freeze_reason - Add indexes on status (operational queries) and status_history (audit queries) - Update CurrentAccountEntity with StatusHistoryJSON GORM type - Enhance repository to persist status_history on state transitions - Add comprehensive integration tests for lifecycle, concurrent ops, and auditing - Fix TestExecuteWithdrawal_AccountClosed to comply with zero-balance close rule The status_history stores each transition with from_status, to_status, reason, timestamp, and changed_by for regulatory compliance and audit purposes. * fix: Move Deprecated notice to separate paragraph for gocritic The gocritic linter requires Deprecated notices to be in a dedicated paragraph, separated from the rest of the documentation. * feat: Address CodeRabbit feedback on status audit trail - Add ChangedBy field to domain.StatusChange struct to preserve audit info - Update toDomain() to populate ChangedBy from persistence layer - Change Close() to accept reason parameter for audit trail - Update all call sites to pass reason (grpc_service, tests) - Add test for default reason behavior when empty string passed * docs: Address minor review feedback with documentation clarifications - Add TODO for Activate() deprecation removal in next major version - Document time.Now() usage and clock injection consideration - Clarify ChangedBy field is populated by persistence layer from context --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent a236ca7 commit 72606a7

12 files changed

Lines changed: 1575 additions & 56 deletions

File tree

api/proto/meridian/current_account/v1/current_account.proto

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,22 @@ enum LienStatus {
8787
LIEN_STATUS_TERMINATED = 3;
8888
}
8989

90+
// ControlAction defines the control operations that can be performed on an account
91+
// These are BIAN Control Record (CoCR) operations for account lifecycle management
92+
enum ControlAction {
93+
// CONTROL_ACTION_UNSPECIFIED is the default value and should not be used
94+
CONTROL_ACTION_UNSPECIFIED = 0;
95+
// CONTROL_ACTION_FREEZE temporarily suspends account operations (reversible)
96+
// Transitions: ACTIVE → FROZEN
97+
CONTROL_ACTION_FREEZE = 1;
98+
// CONTROL_ACTION_UNFREEZE restores a frozen account to active status
99+
// Transitions: FROZEN → ACTIVE
100+
CONTROL_ACTION_UNFREEZE = 2;
101+
// CONTROL_ACTION_CLOSE permanently closes the account (irreversible)
102+
// Transitions: ACTIVE or FROZEN → CLOSED
103+
CONTROL_ACTION_CLOSE = 3;
104+
}
105+
90106
// CurrentAccountFacility represents a BIAN-compliant current account facility
91107
// Task 5.1: Define CurrentAccountFacility message with account identification and status management
92108
message CurrentAccountFacility {
@@ -693,6 +709,79 @@ message RetrieveLienResponse {
693709
Lien lien = 1 [(buf.validate.field).required = true];
694710
}
695711

712+
// Request to update a current account facility settings
713+
// BIAN: Update Control Record (UpCR) - Modify account configuration
714+
message UpdateCurrentAccountRequest {
715+
// account_id is the account to update (required)
716+
string account_id = 1 [(buf.validate.field).string = {
717+
min_len: 1
718+
max_len: 100
719+
pattern: "^[a-zA-Z0-9_-]+$"
720+
}];
721+
722+
// overdraft_limit is the new maximum overdraft amount (optional)
723+
// If provided, updates the overdraft configuration
724+
meridian.common.v1.MoneyAmount overdraft_limit = 2;
725+
726+
// overdraft_enabled controls whether overdraft facility is active (optional)
727+
// Use wrapper to distinguish between "not set" and "set to false"
728+
optional bool overdraft_enabled = 3;
729+
730+
// overdraft_rate is the annual percentage rate for overdraft usage (optional)
731+
// Must be between 0 and 100 inclusive
732+
optional double overdraft_rate = 4 [(buf.validate.field).double = {
733+
gte: 0.0
734+
lte: 100.0
735+
}];
736+
737+
// status is the new account status (optional)
738+
// Note: For status transitions requiring audit trail (freeze/close), use ControlCurrentAccount instead
739+
AccountStatus status = 5 [(buf.validate.field).enum = {
740+
defined_only: true
741+
}];
742+
}
743+
744+
// Response for updating a current account
745+
message UpdateCurrentAccountResponse {
746+
// facility is the updated account facility with new settings
747+
CurrentAccountFacility facility = 1 [(buf.validate.field).required = true];
748+
749+
// version is the new version number after update (for optimistic locking)
750+
int64 version = 2 [(buf.validate.field).int64 = {gte: 0}];
751+
}
752+
753+
// Request to perform a control action on a current account
754+
// BIAN: Control Control Record (CoCR) - Account lifecycle state transitions
755+
message ControlCurrentAccountRequest {
756+
// account_id is the account to control (required)
757+
string account_id = 1 [(buf.validate.field).string = {
758+
min_len: 1
759+
max_len: 100
760+
pattern: "^[a-zA-Z0-9_-]+$"
761+
}];
762+
763+
// control_action is the action to perform (required)
764+
ControlAction control_action = 2 [(buf.validate.field).enum = {
765+
defined_only: true
766+
not_in: [0] /* Prevent UNSPECIFIED */
767+
}];
768+
769+
// reason is an explanation for the control action (required for FREEZE and CLOSE)
770+
// Minimum 10 characters for audit trail completeness
771+
string reason = 3 [(buf.validate.field).string = {
772+
max_len: 1000
773+
}];
774+
}
775+
776+
// Response for controlling a current account
777+
message ControlCurrentAccountResponse {
778+
// facility is the account facility with updated status
779+
CurrentAccountFacility facility = 1 [(buf.validate.field).required = true];
780+
781+
// action_timestamp is when the control action was executed
782+
google.protobuf.Timestamp action_timestamp = 2 [(buf.validate.field).required = true];
783+
}
784+
696785
// CurrentAccountService provides BIAN-compliant current account operations.
697786
//
698787
// Design Notes:
@@ -828,4 +917,32 @@ service CurrentAccountService {
828917
get: "/v1/liens/{lien_id}"
829918
};
830919
}
920+
921+
// UpdateCurrentAccount modifies account configuration settings.
922+
// BIAN: Update Control Record (UpCR) - Updates overdraft settings, status, etc.
923+
// Use this for configuration changes; use ControlCurrentAccount for lifecycle transitions.
924+
rpc UpdateCurrentAccount(UpdateCurrentAccountRequest) returns (UpdateCurrentAccountResponse) {
925+
option (google.api.http) = {
926+
put: "/v1/current-accounts/{account_id}"
927+
body: "*"
928+
};
929+
}
930+
931+
// ControlCurrentAccount performs lifecycle state transitions on an account.
932+
// BIAN: Control Control Record (CoCR) - Freeze, Unfreeze, or Close accounts.
933+
// All control actions are logged with timestamps and reasons for audit compliance.
934+
rpc ControlCurrentAccount(ControlCurrentAccountRequest) returns (ControlCurrentAccountResponse) {
935+
option (google.api.http) = {
936+
post: "/v1/current-accounts/{account_id}/control"
937+
body: "*"
938+
};
939+
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
940+
responses: {
941+
key: "200"
942+
value: {
943+
description: "Control action executed successfully"
944+
}
945+
}
946+
};
947+
}
831948
}

services/current-account/adapters/persistence/account_entity.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@
22
package persistence
33

44
import (
5+
"database/sql/driver"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
59
"time"
610

711
"github.com/google/uuid"
812
)
913

14+
// ErrInvalidStatusHistoryScan is returned when scanning an incompatible type into StatusHistoryJSON.
15+
var ErrInvalidStatusHistoryScan = errors.New("cannot scan into StatusHistoryJSON")
16+
1017
// CurrentAccountEntity represents the database persistence model for current accounts.
1118
// This entity MUST match the schema defined in migrations/current_account/*.sql
1219
// The mapping between domain model and entity is handled by toEntity/toDomain functions.
@@ -28,6 +35,11 @@ type CurrentAccountEntity struct {
2835
BalanceUpdatedAt *time.Time `gorm:"column:balance_updated_at"`
2936
OpenedAt *time.Time `gorm:"column:opened_at;index"`
3037
ClosedAt *time.Time `gorm:"column:closed_at;index"`
38+
FreezeReason *string `gorm:"column:freeze_reason;type:varchar(1000)"` // Reason when account is frozen
39+
40+
// Status audit trail - JSONB array of status changes
41+
// Note: default is handled in code, not database, for GORM AutoMigrate compatibility
42+
StatusHistory StatusHistoryJSON `gorm:"column:status_history;type:jsonb;not null"`
3143

3244
// Optimistic locking
3345
Version int64 `gorm:"column:version;not null;default:1"`
@@ -45,3 +57,43 @@ type CurrentAccountEntity struct {
4557
func (CurrentAccountEntity) TableName() string {
4658
return "account"
4759
}
60+
61+
// StatusHistoryEntry represents a single status change record for audit trail.
62+
type StatusHistoryEntry struct {
63+
FromStatus string `json:"from_status"`
64+
ToStatus string `json:"to_status"`
65+
Reason string `json:"reason"`
66+
Timestamp time.Time `json:"timestamp"`
67+
ChangedBy string `json:"changed_by"`
68+
}
69+
70+
// StatusHistoryJSON is a custom type for handling JSONB status_history column.
71+
type StatusHistoryJSON []StatusHistoryEntry
72+
73+
// Value implements driver.Valuer for database writes.
74+
func (s StatusHistoryJSON) Value() (driver.Value, error) {
75+
if s == nil {
76+
return "[]", nil
77+
}
78+
return json.Marshal(s)
79+
}
80+
81+
// Scan implements sql.Scanner for database reads.
82+
func (s *StatusHistoryJSON) Scan(value interface{}) error {
83+
if value == nil {
84+
*s = StatusHistoryJSON{}
85+
return nil
86+
}
87+
88+
var bytes []byte
89+
switch v := value.(type) {
90+
case []byte:
91+
bytes = v
92+
case string:
93+
bytes = []byte(v)
94+
default:
95+
return fmt.Errorf("%w: unsupported type %T", ErrInvalidStatusHistoryScan, value)
96+
}
97+
98+
return json.Unmarshal(bytes, s)
99+
}

services/current-account/adapters/persistence/lien_repository.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,27 @@ func (r *LienRepository) Update(lien *domain.Lien) error {
190190
return nil
191191
}
192192

193+
// CountActiveByAccountID returns the count of active non-expired liens for an account.
194+
// Used to check if an account has any active liens before closing.
195+
// In multi-org mode, this query is scoped to the organization from context.
196+
func (r *LienRepository) CountActiveByAccountID(ctx context.Context, accountID uuid.UUID) (int64, error) {
197+
var count int64
198+
now := time.Now()
199+
200+
err := r.withTenantTransaction(ctx, func(tx *gorm.DB) error {
201+
result := tx.Model(&LienEntity{}).
202+
Where("account_id = ? AND status = ? AND (expires_at IS NULL OR expires_at > ?)",
203+
accountID, string(domain.LienStatusActive), now).
204+
Count(&count)
205+
return result.Error
206+
})
207+
if err != nil {
208+
return 0, err
209+
}
210+
211+
return count, nil
212+
}
213+
193214
// SumActiveAmountByAccountID returns the total amount of active non-expired liens for an account in cents.
194215
// Returns ErrLienCurrencyInconsistent if liens with different currencies exist (indicates data corruption).
195216
// Currency validation is enforced at the service layer when creating liens (InitiateLien).

services/current-account/adapters/persistence/repository.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ func (r *Repository) Save(ctx context.Context, account domain.CurrentAccount) er
159159
"balance": entity.Balance,
160160
"available_balance": entity.AvailableBalance,
161161
"status": entity.Status,
162+
"freeze_reason": entity.FreezeReason,
163+
"status_history": entity.StatusHistory,
162164
"overdraft_limit": entity.OverdraftLimit,
163165
"overdraft_rate": entity.OverdraftRate,
164166
"balance_updated_at": entity.BalanceUpdatedAt,
@@ -402,6 +404,26 @@ func toEntity(ctx context.Context, account domain.CurrentAccount) (*CurrentAccou
402404

403405
balanceUpdatedAt := account.BalanceUpdatedAt()
404406

407+
// Convert domain StatusHistory to persistence StatusHistoryJSON
408+
domainHistory := account.StatusHistory()
409+
statusHistory := make(StatusHistoryJSON, len(domainHistory))
410+
for i, change := range domainHistory {
411+
statusHistory[i] = StatusHistoryEntry{
412+
FromStatus: string(change.From),
413+
ToStatus: string(change.To),
414+
Reason: change.Reason,
415+
Timestamp: change.Timestamp,
416+
ChangedBy: auditUser,
417+
}
418+
}
419+
420+
// Handle freeze reason - nil if empty
421+
var freezeReason *string
422+
if account.FreezeReason() != "" {
423+
reason := account.FreezeReason()
424+
freezeReason = &reason
425+
}
426+
405427
// ToMinorUnitsUnchecked is safe here: domain layer validates amounts before persistence,
406428
// so overflow (>92 quadrillion cents) cannot occur for valid accounts
407429
return &CurrentAccountEntity{
@@ -417,6 +439,8 @@ func toEntity(ctx context.Context, account domain.CurrentAccount) (*CurrentAccou
417439
OverdraftLimit: account.OverdraftLimit().ToMinorUnitsUnchecked(),
418440
OverdraftRate: account.OverdraftRate(),
419441
BalanceUpdatedAt: &balanceUpdatedAt,
442+
FreezeReason: freezeReason,
443+
StatusHistory: statusHistory,
420444
Version: account.Version(),
421445
CreatedAt: account.CreatedAt(),
422446
UpdatedAt: account.UpdatedAt(),
@@ -453,6 +477,27 @@ func toDomain(entity *CurrentAccountEntity) (domain.CurrentAccount, error) {
453477
balanceUpdatedAt = *entity.BalanceUpdatedAt
454478
}
455479

480+
// Convert persistence StatusHistoryJSON to domain StatusHistory
481+
var statusHistory []domain.StatusChange
482+
if len(entity.StatusHistory) > 0 {
483+
statusHistory = make([]domain.StatusChange, len(entity.StatusHistory))
484+
for i, entry := range entity.StatusHistory {
485+
statusHistory[i] = domain.StatusChange{
486+
From: domain.AccountStatus(entry.FromStatus),
487+
To: domain.AccountStatus(entry.ToStatus),
488+
Reason: entry.Reason,
489+
Timestamp: entry.Timestamp,
490+
ChangedBy: entry.ChangedBy,
491+
}
492+
}
493+
}
494+
495+
// Handle freeze reason - empty string if nil
496+
freezeReason := ""
497+
if entity.FreezeReason != nil {
498+
freezeReason = *entity.FreezeReason
499+
}
500+
456501
// Use builder pattern to construct immutable domain model
457502
return domain.NewCurrentAccountBuilder().
458503
WithID(entity.ID).
@@ -462,6 +507,8 @@ func toDomain(entity *CurrentAccountEntity) (domain.CurrentAccount, error) {
462507
WithBalance(balance).
463508
WithAvailableBalance(availableBalance).
464509
WithStatus(domain.AccountStatus(entity.Status)).
510+
WithFreezeReason(freezeReason).
511+
WithStatusHistory(statusHistory).
465512
WithOverdraftLimit(overdraftLimit).
466513
WithOverdraftEnabled(overdraftEnabled).
467514
WithOverdraftRate(entity.OverdraftRate).

0 commit comments

Comments
 (0)