# Wallet Utils Usage Guide

This package provides wallet point transaction utilities, ported from the PHP `WalletPoint` class.

## Installation

This is a Django package that can be installed in several ways:

### Option 1: Install from Local Path (Recommended for Development)

For development or when working with a local copy of the package:

```bash
# From your Django project directory
pip install -e /path/to/django-wallet-utils

# Example: If the package is in a sibling directory
pip install -e ../django-wallet-utils

# Example: If the package is in an absolute path
pip install -e /home/cursorai/projects/django-wallet-utils
```

The `-e` flag installs the package in "editable" mode, meaning changes to the source code will be immediately available without reinstalling.

### Option 2: Install from Git Repository (Recommended for Production)

Install directly from the Git repository:

```bash
pip install git+https://github.com/mscumec/django-downline-utils.git
```

For a specific branch or tag:

```bash
# Install from a specific branch
pip install git+https://github.com/mscumec/django-downline-utils.git@branch-name

# Install from a specific tag/version
pip install git+https://github.com/mscumec/django-downline-utils.git@v0.1.0
```

### Adding to requirements.txt

For local development:
```txt
-e /path/to/django-wallet-utils
```

For production (from Git):
```txt
django-wallet-utils @ git+https://github.com/mscumec/django-downline-utils.git
```

## Quick Start

### 1. Add to Django INSTALLED_APPS

Add `wallet_utils` to your `INSTALLED_APPS` in `settings.py`:

```python
INSTALLED_APPS = [
    # ... other apps
    "wallet_utils",
]
```

### 2. Run Migrations

The package includes its own migrations for the `WalletTransaction` model:

```bash
python manage.py migrate wallet_utils
```

### 3. Define Point Types and Use the Service

The repository only needs to define point types and their decimal places:

```python
from decimal import Decimal
from wallet_utils import WalletService, WalletRepository
from wallet_utils.exceptions import InsufficientBalanceError
from your_app.models import User

# Define point types and their decimal places
point_types = {
    "credit_balance": 2,  # 2 decimal places
    "reward_points": 0,   # No decimal places (integer)
    "crypto_balance": 8,  # 8 decimal places
}

# Option 1: Use User model for balances (default)
repo = WalletRepository(
    user_model=User,
    point_types=point_types,
)

# Option 2: Use separate Wallet model for balances
from your_app.models import Wallet

repo = WalletRepository(
    user_model=User,
    wallet_balance_model=Wallet,  # Separate model with user_id field
    point_types=point_types,
)

# Use the service
service = WalletService(repo)

# Add points (system-initiated, uses default iid=-100)
from wallet_utils.transaction_types import WALLET_DEPOSIT

try:
    transaction_id = service.add_point(
        user_id=123,
        point_type="credit_balance",
        amount=Decimal("100.00"),
        remarks="Deposit from payment",
        trans_type=WALLET_DEPOSIT,  # Use transaction type constant
    )
    print(f"Transaction created: {transaction_id}")
except Exception as e:
    print(f"Error: {e}")

# Add points (user-initiated)
from wallet_utils.transaction_types import ROI_DISTRIBUTION

try:
    transaction_id = service.add_point(
        user_id=123,
        point_type="credit_balance",
        amount=Decimal("50.00"),
        remarks="Reward from ROI",
        trans_type=ROI_DISTRIBUTION,  # Use transaction type constant
        iid=456,  # User 456 initiated this transaction
    )
    print(f"Transaction created: {transaction_id}")
except Exception as e:
    print(f"Error: {e}")

# Deduct points (uses atomic SQL with WHERE clause)
from wallet_utils.transaction_types import WALLET_WITHDRAW

try:
    transaction_id = service.deduct_point(
        user_id=123,
        point_type="credit_balance",
        amount=Decimal("50.00"),
        remarks="Withdrawal request",
        trans_type=WALLET_WITHDRAW,  # Use transaction type constant
        allow_negative=False,
        iid=123,  # User 123 initiated this withdrawal
    )
    print(f"Transaction created: {transaction_id}")
except InsufficientBalanceError as e:
    print(f"Insufficient balance: Available {e.available}, Requested {e.requested}")
except Exception as e:
    print(f"Error: {e}")
```

## Custom Field Names

If your User model field names differ from point type names, use `point_type_field_map`:

```python
repo = WalletRepository(
    user_model=User,
    point_types={
        "credit_balance": 2,
        "reward_points": 0,
    },
    point_type_field_map={
        "credit_balance": "credit_balance",  # Default: same as point_type
        "reward_points": "points",           # Custom: field is "points" not "reward_points"
    },
)
```

## Separate Wallet Balance Model

By default, wallet balances are stored directly on the User model. You can use a separate model for storing balances:

```python
from wallet_utils import WalletRepository
from your_app.models import User, Wallet

# Your Wallet model should have:
# - user_id field (or specify custom field name)
# - Fields for each point type (e.g., credit_balance, reward_points)

class Wallet(models.Model):
    user_id = models.BigIntegerField(unique=True, db_index=True)
    credit_balance = models.DecimalField(max_digits=20, decimal_places=2, default=0)
    reward_points = models.DecimalField(max_digits=20, decimal_places=0, default=0)
    # ... other point type fields

# Use separate wallet model
repo = WalletRepository(
    user_model=User,
    wallet_balance_model=Wallet,
    point_types={
        "credit_balance": 2,
        "reward_points": 0,
    },
    wallet_user_id_field="user_id",  # Optional: defaults to "user_id"
)

# If your wallet model uses a different field name for user reference:
repo = WalletRepository(
    user_model=User,
    wallet_balance_model=Wallet,
    point_types={"credit_balance": 2},
    wallet_user_id_field="owner_id",  # Custom field name
)
```

## Custom Wallet Transaction Model

You can use your own model for storing transactions:

```python
from wallet_utils import WalletRepository
from wallet_utils.models import AbstractWalletTransaction, WalletTransaction
from your_app.models import User

# Option 1: Use default WalletTransaction model
repo = WalletRepository(
    user_model=User,
    point_types={"credit_balance": 2},
)

# Option 2: Extend AbstractWalletTransaction for custom table (only one table created)
class CustomTransaction(AbstractWalletTransaction):
    """Your custom transaction model."""
    class Meta(AbstractWalletTransaction.Meta):
        db_table = "custom_transactions"

repo = WalletRepository(
    user_model=User,
    point_types={"credit_balance": 2},
    wallet_model=CustomTransaction,
)

# Option 3: Extend WalletTransaction for same table with custom fields
class CustomTransactionSameTable(WalletTransaction):
    """Custom transaction model using same table."""
    amount = models.DecimalField(max_digits=20, decimal_places=8)
    balance = models.DecimalField(max_digits=20, decimal_places=8)
    
    class Meta:
        db_table = "wallet_transactions"  # Same table as parent

repo = WalletRepository(
    user_model=User,
    point_types={"credit_balance": 8},
    wallet_model=CustomTransactionSameTable,
)
```

**Important**: 
- Use `AbstractWalletTransaction` if you want a custom table name and only one table created
- Use `WalletTransaction` if you want to use the default table or customize fields on the same table
- See "Customizing Decimal Places" section for more details

## Features

### Multiple Point Types

The service supports multiple wallet/point types. Define them in `point_types` dictionary mapping point type names to their decimal places.

### Transaction Recording

All operations automatically create transaction records in the `WalletTransaction` model. Transactions are always saved to the database.

### Transaction Types

Transaction types are represented as integer constants. Import them from `wallet_utils.transaction_types`:

```python
from wallet_utils.transaction_types import (
    WALLET_DEPOSIT,
    WALLET_WITHDRAW,
    PACKAGE_ACTIVATION,
    COMMISSION_DISTRIBUTION,
    ROI_DISTRIBUTION,
    PRODUCT_ORDER_PARTIAL_REFUND,
    # ... and many more
)

# Use in transactions
service.add_point(
    user_id=123,
    point_type="credit_balance",
    amount=Decimal("100.00"),
    remarks="Deposit",
    trans_type=WALLET_DEPOSIT,  # Integer constant: 1000
)
```

**Available Transaction Type Categories:**

- **Wallet Operations (1000-1999)**: `WALLET_DEPOSIT`, `WALLET_WITHDRAW`, `WALLET_TRANSFER`, etc.
- **Package Operations (2000-2999)**: `PACKAGE_ACTIVATION`, `PACKAGE_UPGRADE`, `PACKAGE_RENEWAL`, etc.
- **Commission & Rewards (3000-3999)**: `COMMISSION_DISTRIBUTION`, `ROI_DISTRIBUTION`, etc.
- **Product Operations (4000-4999)**: `PRODUCT_ORDER`, `PRODUCT_ORDER_PARTIAL_REFUND`, `PRODUCT_REDEMPTION`, etc.
- **System Operations (5000-5999)**: `SYSTEM_ADJUSTMENT`, `SYSTEM_REWARD`, etc.

You can also use helper functions:

```python
from wallet_utils.transaction_types import get_transaction_type, get_transaction_type_name

# Get integer constant from string name
trans_type = get_transaction_type("wallet-deposit")  # Returns 1000

# Get string name from integer constant
name = get_transaction_type_name(1000)  # Returns "wallet-deposit"
```

**Note**: `trans_type` is optional and can be `None` if you don't need to categorize the transaction.

### Extra Data

Pass additional fields via `params["data"]`:

```python
from wallet_utils.transaction_types import ROI_DISTRIBUTION

service.add_point(
    user_id=123,
    point_type="credit_balance",
    amount=Decimal("100.00"),
    remarks="Reward",
    trans_type=ROI_DISTRIBUTION,  # Use transaction type constant
    iid=-100,  # System-initiated
    params={
        "data": {
            "investment_id": 456,
            "reward_type": "daily",
        }
    },
)
```

### Initiator ID (iid)

The `iid` parameter indicates which user performed the transaction:
- Use a positive user ID for user-initiated transactions (e.g., `iid=123`)
- Use `-100` for system-initiated transactions (this is the default)
- The `iid` is stored in the transaction record for audit purposes

### Decimal Precision

Decimal precision is automatically determined from the point type definition. For example, if you define `"crypto_balance": 8`, all operations on that point type will use 8 decimal places.

### Atomic Deduction with Race Condition Prevention

The `deduct_point()` method uses atomic SQL operations with a WHERE clause to prevent race conditions:

```sql
UPDATE users 
SET credit_balance = credit_balance - 50.00
WHERE id = 123 AND credit_balance >= 50.00
```

The method checks if any rows were affected (`rowCount > 0`) to confirm the deduction was successful. This ensures:
- No race conditions (database-level locking)
- Accurate balance checking (no need to check balance separately)
- Atomic operations (all-or-nothing)

## Transaction History Retrieval

The service provides methods to retrieve transaction history with various filters:

```python
from datetime import datetime, timedelta

# Get all transactions for a user
transactions = service.get_transaction_history(user_id=123)

# Get credit transactions for a specific point type
transactions = service.get_transaction_history(
    user_id=123,
    point_type="credit_balance",
    transaction_type="c",  # 'c' for credit, 'd' for debit
)

# Get transactions in date range with pagination
end = datetime.now()
start = end - timedelta(days=30)
transactions = service.get_transaction_history(
    user_id=123,
    start_date=start,
    end_date=end,
    limit=100,
    offset=0,
)

# Get transactions by transaction type code
from wallet_utils.transaction_types import WALLET_DEPOSIT

transactions = service.get_transaction_history(
    user_id=123,
    trans_type=WALLET_DEPOSIT,  # Filter by transaction type integer constant
)

# Get transactions initiated by a specific user
transactions = service.get_transaction_history(
    iid=456,  # User 456 initiated these transactions
)

# Get a single transaction by ID
transaction = service.get_transaction_by_id(transaction_id=789)
if transaction:
    print(f"Amount: {transaction.amount}, Balance: {transaction.balance}")

# Count transactions matching filters
from wallet_utils.transaction_types import WALLET_DEPOSIT

count = service.count_transactions(
    user_id=123,
    point_type="credit_balance",
    transaction_type="c",
    trans_type=WALLET_DEPOSIT,  # Filter by transaction type
    start_date=start,
    end_date=end,
)
print(f"Found {count} transactions")
```

### Available Filters

- `user_id`: Filter by user ID (maps to `uid` field)
- `point_type`: Filter by wallet/point type (maps to `wtype` field)
- `trans_type`: Filter by transaction type code (maps to `trans_type` field) - integer constant (e.g., `WALLET_DEPOSIT`)
- `transaction_type`: Filter by transaction type ('c' for credit, 'd' for debit) (maps to `type` field)
- `iid`: Filter by initiator ID (maps to `iid` field) - user who performed the transaction (-100 for system)
- `start_date`: Filter transactions from this date (inclusive) - datetime or ISO string (maps to `cdate` field)
- `end_date`: Filter transactions until this date (inclusive) - datetime or ISO string (maps to `cdate` field)
- `limit`: Maximum number of records to return
- `offset`: Number of records to skip (for pagination)

Transactions are returned ordered by creation date (newest first).

## Error Handling

The package uses custom exceptions:

- `WalletOperationError`: Base exception for wallet operations
- `InsufficientBalanceError`: Insufficient balance (includes available/requested amounts)
- `InvalidPointTypeError`: Point type doesn't exist
- `InvalidParamsError`: Invalid parameters

```python
from wallet_utils.exceptions import (
    InsufficientBalanceError,
    InvalidPointTypeError,
    InvalidParamsError,
)

try:
    service.deduct_point(...)
except InsufficientBalanceError as e:
    # Handle insufficient balance
    print(f"Available: {e.available}, Requested: {e.requested}")
except InvalidPointTypeError as e:
    # Handle invalid point type
    print(f"Invalid point type: {e.point_type}")
except InvalidParamsError as e:
    # Handle invalid parameters
    print(f"Invalid params in {e.method}")
```

## Custom Repository Implementation

If you need custom behavior, you can implement your own repository:

```python
from wallet_utils import WalletRepository, WalletService
from your_app.models import User

class CustomRepository(WalletRepository):
    """Custom repository with additional logic."""
    
    def get_point_types(self) -> dict[str, int]:
        # Load from database, config, etc.
        return {
            "credit_balance": 2,
            "reward_points": 0,
        }
    
    def get_user_model(self):
        return User

# Use custom repository
repo = CustomRepository(user_model=User, point_types={...})
service = WalletService(repo)
```

## Database Schema

### WalletTransaction Models

The package includes two models:

1. **`AbstractWalletTransaction`**: Abstract base model (does not create a table). Extend this if you want a custom table name and only one table created.
2. **`WalletTransaction`**: Concrete model that extends `AbstractWalletTransaction` and creates the `wallet_transactions` table.

Both models have the same fields. The `WalletTransaction` model has the following fields:

#### Fields

- **`id`** (AutoField): Primary key, auto-incrementing integer
- **`wtype`** (CharField, max_length=50, indexed): Wallet/point type (e.g., "credit_balance", "reward_points")
- **`iid`** (BigIntegerField, indexed): Initiator ID - user who performed this transaction (-100 for system-initiated transactions)
- **`uid`** (BigIntegerField, indexed): User ID - owner of the wallet
  - **Note**: This field does NOT use a forced foreign key constraint. This allows flexibility for special cases like system user IDs (e.g., -100) that may not exist in the User table
- **`type`** (CharField, max_length=1, choices): Transaction type
  - `"c"` = Credit/Add
  - `"d"` = Debit/Deduct
- **`amount`** (DecimalField, max_digits=20, decimal_places=2): Transaction amount (default: 2 decimal places)
- **`balance`** (DecimalField, max_digits=20, decimal_places=2): Balance after this transaction (default: 2 decimal places)
- **`trans_type`** (IntegerField, indexed, null=True, blank=True): Transaction type code (integer constant, e.g., 1000 for wallet-deposit)
- **`descr`** (TextField, blank=True, default=""): Remarks/description
- **`cdate`** (DateTimeField, auto_now_add=True, indexed): Creation date/time
- **`extra_data`** (JSONField, default=dict, blank=True): Additional fields stored as JSON

#### Indexes

The model includes the following database indexes for optimal query performance:

1. **Single field indexes** (on fields with `db_index=True`):
   - `wtype` - For filtering by wallet/point type
   - `iid` - For filtering by initiator ID
   - `uid` - For filtering by user ID
   - `trans_type` - For filtering by transaction type code
   - `cdate` - For date range queries and ordering

2. **Composite indexes** (defined in `Meta.indexes`):
   - `(uid, wtype)` - For querying user's transactions by point type
   - `(uid, cdate)` - For querying user's transactions by date
   - `(wtype, trans_type)` - For querying transactions by point type and transaction code
   - `(iid, cdate)` - For querying transactions by initiator and date

#### Model Configuration

- **Table name**: `wallet_transactions` (can be customized via `db_table` in Meta)
- **Default ordering**: `-cdate` (newest first)
- **No foreign key constraints**: The `uid` field does not enforce a foreign key relationship, allowing flexibility for system users and special cases

#### Example Model Definitions

```python
from wallet_utils.models import AbstractWalletTransaction, WalletTransaction

# Abstract base model (does not create a table)
class AbstractWalletTransaction(models.Model):
    # ... all fields defined here ...
    class Meta:
        abstract = True
        # ... indexes and ordering ...

# Concrete model (creates wallet_transactions table)
class WalletTransaction(AbstractWalletTransaction):
    class Meta(AbstractWalletTransaction.Meta):
        db_table = "wallet_transactions"

# Custom model extending abstract (creates only custom_wallet_trans table)
class CustomWalletTransaction(AbstractWalletTransaction):
    amount = models.DecimalField(max_digits=20, decimal_places=8)
    balance = models.DecimalField(max_digits=20, decimal_places=8)
    
    class Meta(AbstractWalletTransaction.Meta):
        db_table = "custom_wallet_trans"  # Only this table will be created
```

#### Customizing Decimal Places and Table Names

The `WalletTransaction` model uses **2 decimal places** as the default for `amount` and `balance` fields. If you need different decimal precision or a custom table name, you have two options:

##### Option 1: Extend AbstractWalletTransaction (Recommended for Custom Tables)

To maintain **only ONE table** when using a custom table name, extend `AbstractWalletTransaction` instead of `WalletTransaction`:

```python
from wallet_utils.models import AbstractWalletTransaction
from django.db import models

class CustomWalletTransaction(AbstractWalletTransaction):
    """Custom transaction model with different decimal places and table name."""
    
    amount = models.DecimalField(max_digits=20, decimal_places=8)  # 8 decimal places
    balance = models.DecimalField(max_digits=20, decimal_places=8)  # 8 decimal places
    
    class Meta(AbstractWalletTransaction.Meta):
        db_table = "custom_wallet_trans"  # Only this table will be created

# Use your custom model
repo = WalletRepository(
    user_model=User,
    point_types={"credit_balance": 8},  # Match decimal places
    wallet_model=CustomWalletTransaction,  # Use custom model
)
```

**Important: Single Table with AbstractWalletTransaction**

When you extend `AbstractWalletTransaction` with a custom `db_table`:
- **Only ONE table will be created** - your custom table (e.g., `custom_wallet_trans`)
- The default `wallet_transactions` table will **NOT** be created (since `AbstractWalletTransaction` is abstract)
- You must create and run migrations for your custom model in your app
- You can still add `wallet_utils` to `INSTALLED_APPS` for the package functionality, but you should **skip running `wallet_utils` migrations** if you don't want the default table

**To skip wallet_utils migrations:**
```python
# In your settings.py
MIGRATION_MODULES = {
    'wallet_utils': None,  # Skip migrations for wallet_utils
}
```

Or simply don't run `python manage.py migrate wallet_utils` - only run migrations for your app.

##### Option 2: Extend WalletTransaction (For Same Table)

If you want to use the same `wallet_transactions` table but customize fields:

```python
from wallet_utils.models import WalletTransaction
from django.db import models

class CustomWalletTransaction(WalletTransaction):
    """Custom transaction model with different decimal places."""
    
    amount = models.DecimalField(max_digits=20, decimal_places=8)  # 8 decimal places
    balance = models.DecimalField(max_digits=20, decimal_places=8)  # 8 decimal places
    
    class Meta:
        db_table = "wallet_transactions"  # Use same table as parent
        # Copy indexes from parent if needed
        indexes = [
            models.Index(fields=["uid", "wtype"]),
            models.Index(fields=["uid", "cdate"]),
            models.Index(fields=["wtype", "trans_type"]),
            models.Index(fields=["iid", "cdate"]),
        ]
        ordering = ["-cdate"]

# Use your custom model
repo = WalletRepository(
    user_model=User,
    point_types={"credit_balance": 8},  # Match decimal places
    wallet_model=CustomWalletTransaction,  # Use custom model
)
```

**Note**: When extending `WalletTransaction` (concrete model):
- If `db_table = "wallet_transactions"` (same as parent): Both models use the same table
- If `db_table = "custom_wallet_trans"` (different name): **TWO tables will be created** - both `wallet_transactions` (from `wallet_utils` migrations) and `custom_wallet_trans` (from your migrations)
- If you omit `db_table`: Multi-table inheritance is created (not recommended)

**Recommendation**: 
- **For custom table names**: Use `AbstractWalletTransaction` (Option 1) to ensure only one table is created
- **For same table with custom fields**: Use `WalletTransaction` with `db_table = "wallet_transactions"` (Option 2)
- **Never omit `db_table`** unless you intentionally want separate tables (not recommended)

**Note**: Make sure to:
1. Create and run migrations for your custom model
2. Set `wallet_model=CustomWalletTransaction` when initializing `WalletRepository`
3. Match the `decimal_places` in your `point_types` definition with the model field's `decimal_places`
4. Copy all indexes from the parent model if you want the same query performance

##### Option 2: Modify After Installation (Not Recommended)

If you installed the package via `pip install`, you can modify the model directly in the installed package location:

```bash
# Find the installed package location
python -c "import wallet_utils; print(wallet_utils.__file__)"

# Edit the models.py file in that location
# Change decimal_places from 2 to your desired value
```

**Warning**: This approach is **not recommended** because:
- Changes will be lost when you reinstall or update the package
- Makes your codebase harder to maintain
- Can cause issues in team environments
- Requires manual migration updates

**Recommendation**: Always use Option 1 (extending the model) for production code. It's cleaner, maintainable, and follows Django best practices.

## Transaction Type Constants

The package includes a comprehensive set of transaction type constants organized by category:

### Wallet Operations (1000-1999)
- `WALLET_DEPOSIT` (1000)
- `WALLET_DEPOSIT_CANCEL` (1001)
- `WALLET_TRANSFER` (1002)
- `WALLET_WITHDRAW` (1003)
- `WALLET_WITHDRAW_REFUND` (1004)
- `WALLET_ADJUST` (1005)
- `WALLET_TOPUP` (1006)
- `WALLET_DEDUCT` (1007)

### Package Operations (2000-2999)
- `PACKAGE_ACTIVATION` (2000)
- `PACKAGE_ACTIVATION_CANCEL` (2001)
- `PACKAGE_UPGRADE` (2002)
- `PACKAGE_UPGRADE_CANCEL` (2003)
- `PACKAGE_RENEWAL` (2004)
- `PACKAGE_RENEWAL_CANCEL` (2005)
- `PACKAGE_EXPIRY_REFUND` (2006)

### Commission & Rewards (3000-3999)
- `COMMISSION_DISTRIBUTION` (3000)
- `COMMISSION_DISTRIBUTION_REVERSE` (3001)
- `ROI_DISTRIBUTION` (3100)
- `ROI_DISTRIBUTION_REVERSE` (3101)

### Product Operations (4000-4999)
- `PRODUCT_ORDER` (4000)
- `PRODUCT_ORDER_CANCEL` (4001)
- `PRODUCT_ORDER_REFUND` (4002)
- `PRODUCT_ORDER_PARTIAL_REFUND` (4003)
- `PRODUCT_REDEMPTION` (4004)
- `PRODUCT_REDEMPTION_CANCEL` (4005)

### System Operations (5000-5999)
- `SYSTEM_ADJUSTMENT` (5000)
- `SYSTEM_REWARD` (5001)
- `SYSTEM_PENALTY` (5002)
- `SYSTEM_REVERSAL` (5003)
- `SYSTEM_MIGRATION` (5004)

**Usage:**
```python
from wallet_utils import WALLET_DEPOSIT, ROI_DISTRIBUTION, WALLET_WITHDRAW

# Or import from transaction_types module
from wallet_utils.transaction_types import WALLET_DEPOSIT

service.add_point(
    user_id=123,
    point_type="credit_balance",
    amount=Decimal("100.00"),
    remarks="Deposit",
    trans_type=WALLET_DEPOSIT,  # Use constant instead of string
)
```

## Extending Transaction Types

If you need transaction types that aren't included in the built-in set, you can register custom transaction types using the extension API.

### Reserved Range for Custom Types

Custom transaction types should use the reserved range **10000-19999** to avoid conflicts with built-in types. The package provides constants for this range:

```python
from wallet_utils.transaction_types import (
    CUSTOM_TRANSACTION_TYPE_RANGE_START,  # 10000
    CUSTOM_TRANSACTION_TYPE_RANGE_END,    # 19999
)
```

### Registering Custom Transaction Types

Use `register_custom_transaction_type()` to add your own transaction types:

```python
from wallet_utils.transaction_types import (
    register_custom_transaction_type,
    get_transaction_type,
    get_transaction_type_name,
)
from decimal import Decimal

# Register custom transaction types
register_custom_transaction_type("custom-reward", 10000)
register_custom_transaction_type("custom-penalty", 10001)
register_custom_transaction_type("affiliate-bonus", 10002)

# Use the custom types
custom_reward_type = get_transaction_type("custom-reward")  # Returns 10000

service.add_point(
    user_id=123,
    point_type="credit_balance",
    amount=Decimal("50.00"),
    remarks="Custom reward",
    trans_type=custom_reward_type,  # Use custom type
)

# Get the name back from the integer value
name = get_transaction_type_name(10000)  # Returns "custom-reward"
```

### Best Practices

1. **Use the Reserved Range**: Always use values between 10000-19999 for custom types
2. **Use Descriptive Names**: Choose clear, kebab-case names (e.g., "affiliate-bonus", "referral-reward")
3. **Register Early**: Register custom types at application startup (e.g., in `apps.py` or `settings.py`)
4. **Document Your Types**: Keep a list of your custom types and their purposes

### Example: Registering Custom Types at Startup

```python
# In your_app/apps.py
from django.apps import AppConfig
from wallet_utils.transaction_types import register_custom_transaction_type


class YourAppConfig(AppConfig):
    default_auto_field = "django.db.models.BigIntegerField"
    name = "your_app"

    def ready(self):
        # Register custom transaction types when app is ready
        register_custom_transaction_type("affiliate-bonus", 10000)
        register_custom_transaction_type("referral-reward", 10001)
        register_custom_transaction_type("loyalty-points", 10002)
```

### Managing Custom Types

```python
from wallet_utils.transaction_types import (
    register_custom_transaction_type,
    unregister_custom_transaction_type,
    get_custom_transaction_types,
)

# Register a type
register_custom_transaction_type("custom-reward", 10000)

# Get all custom types
custom_types = get_custom_transaction_types()
print(custom_types)  # {'custom-reward': 10000}

# Unregister a type (cannot unregister built-in types)
unregister_custom_transaction_type("custom-reward")

# Override an existing custom type
register_custom_transaction_type("custom-reward", 10001, override=True)
```

### Error Handling

The registration function validates inputs and raises `ValueError` for invalid operations:

```python
try:
    # This will raise ValueError - value must be in range 10000-19999
    register_custom_transaction_type("invalid", 5000)
except ValueError as e:
    print(e)  # "Custom transaction type value 5000 must be in range 10000-19999"

try:
    # This will raise ValueError - name already exists
    register_custom_transaction_type("wallet-deposit", 10000)
except ValueError as e:
    print(e)  # "Transaction type name 'wallet-deposit' already exists..."
```

### Notes

- Custom types are stored in memory and persist for the lifetime of the application
- Built-in types cannot be unregistered or overridden
- Custom types work with all helper functions (`get_transaction_type()`, `get_transaction_type_name()`, etc.)
- The `trans_type` parameter in `add_point()` and `deduct_point()` accepts any integer, so custom types work seamlessly

## Hook System

The hook system allows you to intercept and extend wallet transactions with custom logic. You can register hooks that execute **before** (PRE) or **after** (POST) transactions.

### Key Features

- **PRE Hooks**: Execute before transaction, can reject with validation errors
- **POST Hooks**: Execute after transaction, for side effects (logging, notifications)
- **Priority System**: Control execution order (lower number = higher priority)
- **Error Handling**: Structured error codes for frontend integration
- **Metadata Bus**: Share data between hooks
- **Operation Specific**: Register for specific operations or all operations

### Quick Example

```python
from decimal import Decimal
from wallet_utils import WalletService, register_hook, HookType, HookContext, HookRejectionError

# Create service with hooks enabled (default)
service = WalletService(repository)

# Register a PRE hook to check daily limits
def check_daily_limit(context: HookContext) -> bool:
    # Get user's daily transaction total
    daily_total = get_user_daily_total(context.user_id, context.point_type)
    
    if daily_total + context.amount > Decimal("1000.00"):
        raise HookRejectionError(
            error_code="DAILY_LIMIT_EXCEEDED",
            message=f"Daily limit of $1,000 exceeded",
            details={
                "daily_total": float(daily_total),
                "requested": float(context.amount),
                "limit": 1000.00
            }
        )
    return True

# Register the hook
register_hook(
    name="daily_limit_checker",
    hook_type=HookType.PRE,
    callback=check_daily_limit,
    operation="deduct",  # Only for deduct operations
    priority=50  # Higher priority (lower number)
)

# Now all deduct operations will be checked
result = service.deduct_point(
    user_id=123,
    point_type="cash",
    amount=Decimal("500.00"),
    remarks="Purchase"
)

if result.success:
    print(f"Transaction successful: {result.transaction_id}")
else:
    print(f"Transaction rejected: {result.error_code}")
    print(f"Message: {result.error_message}")
    print(f"Details: {result.error_details}")
```

### Hook Types

#### PRE Hooks (Validation)

Execute **before** the transaction. Can reject the transaction.

```python
def validate_amount(context: HookContext) -> bool:
    if context.amount > Decimal("10000.00"):
        raise HookRejectionError(
            error_code="AMOUNT_TOO_HIGH",
            message="Amount exceeds maximum limit of $10,000",
            details={"max_limit": 10000.00, "requested": float(context.amount)}
        )
    return True

register_hook(
    name="amount_validator",
    hook_type=HookType.PRE,
    callback=validate_amount,
    operation="*",  # Apply to all operations
    priority=10  # Very high priority
)
```

**Rejection Methods:**
1. Return `False` (simple rejection)
2. Raise `HookRejectionError` with error code and details (recommended)

#### POST Hooks (Side Effects)

Execute **after** the transaction. Cannot reject, only capture errors.

```python
def log_transaction(context: HookContext) -> None:
    # Log to audit trail
    logger.info(
        f"Transaction completed: {context.operation} "
        f"user={context.user_id} amount={context.amount}"
    )
    
    # Send notification
    send_email_notification(
        user_id=context.user_id,
        subject="Transaction Completed",
        message=f"Your {context.operation} of {context.amount} {context.point_type} was successful"
    )

register_hook(
    name="transaction_logger",
    hook_type=HookType.POST,
    callback=log_transaction,
    operation="*",
    priority=100  # Normal priority
)
```

### Hook Context

All hooks receive a `HookContext` object with transaction details:

```python
@dataclass(frozen=True)
class HookContext:
    # Operation details
    operation: str              # 'add', 'deduct', or 'transfer'
    user_id: int               # User performing transaction
    point_type: str            # Type of points
    amount: Decimal            # Transaction amount
    remarks: str               # Transaction description
    trans_type: Optional[int]  # Transaction type code
    iid: int                   # Initiator ID
    
    # Transfer-specific (None for add/deduct)
    to_user_id: Optional[int]
    to_point_type: Optional[str]
    
    # Additional data
    params: Dict[str, Any]              # Extra parameters
    metadata: Dict[str, Any]            # Mutable metadata bus
    current_balance: Optional[Decimal]  # Balance before transaction
```

**Important Notes:**
- All fields except `metadata` are **immutable** (frozen)
- Use `context.metadata` to share data between hooks
- Access current balance with `context.current_balance`

### Priority System

Hooks execute in order of priority (lower number = higher priority):

```python
# High priority - security checks
register_hook("fraud_check", HookType.PRE, fraud_check_hook, priority=10)

# Medium priority - business rules
register_hook("daily_limit", HookType.PRE, daily_limit_hook, priority=50)

# Normal priority - logging
register_hook("audit_log", HookType.POST, audit_log_hook, priority=100)

# Low priority - notifications
register_hook("email_notify", HookType.POST, email_hook, priority=500)
```

**Execution Order:**
1. Sort by priority (ascending)
2. Within same priority, by registration order
3. Global hooks (`*`) and operation-specific hooks are combined

### Operation Targeting

Target specific operations or all operations:

```python
# Only for deduct operations
register_hook("check_balance", HookType.PRE, check_hook, operation="deduct")

# Only for transfers
register_hook("notify_recipient", HookType.POST, notify_hook, operation="transfer")

# All operations (default)
register_hook("audit_all", HookType.POST, audit_hook, operation="*")
```

### Error Handling

#### PRE Hook Rejection

When a PRE hook rejects, the transaction fails immediately:

```python
result = service.deduct_point(
    user_id=123,
    point_type="cash",
    amount=Decimal("500.00"),
    remarks="Purchase"
)

if not result.success:
    print(f"Error Code: {result.error_code}")      # "DAILY_LIMIT_EXCEEDED"
    print(f"Error Message: {result.error_message}") # Human-readable message
    print(f"Error Details: {result.error_details}") # Dict with extra info
```

**Frontend Integration:**

```typescript
interface TransactionResult {
  success: boolean;
  transaction_id?: number;
  error?: {
    code: string;          // ALL_CAPS_ERROR_CODE
    message: string;       // Human-readable
    details: object;       // Extra context
  };
}

// Example error response
{
  "success": false,
  "error": {
    "code": "DAILY_LIMIT_EXCEEDED",
    "message": "Daily limit of $1,000 exceeded",
    "details": {
      "daily_total": 850.00,
      "requested": 500.00,
      "limit": 1000.00
    }
  }
}
```

#### POST Hook Errors

POST hooks cannot stop the transaction. Errors are captured as warnings:

```python
result = service.add_point(
    user_id=123,
    point_type="cash",
    amount=Decimal("100.00"),
    remarks="Deposit"
)

# Transaction succeeded
print(result.success)  # True
print(result.transaction_id)  # 456

# But POST hook had errors
if result.post_hook_errors:
    for error in result.post_hook_errors:
        print(f"Warning: {error.hook_name} failed")
        print(f"Code: {error.error_code}")
        print(f"Message: {error.message}")
```

**API Response with Warnings:**

```json
{
  "success": true,
  "transaction_id": 456,
  "warnings": [
    {
      "hook": "email_notification",
      "code": "EMAIL_SERVICE_UNAVAILABLE",
      "message": "Failed to send email notification",
      "details": {
        "exception_type": "SMTPException"
      }
    }
  ]
}
```

### Metadata Bus

Share data between hooks using the mutable `metadata` dict:

```python
def first_hook(context: HookContext) -> bool:
    # Perform expensive calculation
    risk_score = calculate_fraud_risk(context.user_id)
    
    # Store in metadata for other hooks
    context.metadata["risk_score"] = risk_score
    
    return risk_score < 0.8

def second_hook(context: HookContext) -> bool:
    # Reuse calculation from first hook
    risk_score = context.metadata.get("risk_score", 0.0)
    
    if risk_score > 0.5:
        # Apply extra validation for risky users
        pass
    
    return True

# Register in order
register_hook("fraud_risk", HookType.PRE, first_hook, priority=10)
register_hook("extra_check", HookType.PRE, second_hook, priority=20)
```

### Managing Hooks

```python
from wallet_utils import register_hook, unregister_hook, clear_hooks

# Register a hook
register_hook("my_hook", HookType.PRE, my_callback)

# Unregister by name
unregister_hook("my_hook")  # Returns True if found

# Clear all hooks (useful for testing)
clear_hooks()
```

### TransactionResult

When hooks are enabled, service methods return `TransactionResult`:

```python
@dataclass
class TransactionResult:
    success: bool                              # Transaction succeeded?
    transaction_id: Optional[int]              # ID if successful
    error_code: Optional[str]                  # Error code if failed
    error_message: Optional[str]               # Human message if failed
    error_details: Optional[Dict[str, Any]]    # Extra info if failed
    post_hook_errors: Optional[List[PostHookError]]  # POST hook warnings

# Convert to dict for API responses
result_dict = result.to_dict()
```

### Example: Complete Hook Implementation

```python
from decimal import Decimal
from datetime import datetime, timedelta
from typing import Dict
from wallet_utils import (
    WalletService,
    register_hook,
    HookType,
    HookContext,
    HookRejectionError,
)

# Track daily totals (in production, use Redis or database)
daily_totals: Dict[tuple, Decimal] = {}

def check_daily_limit(context: HookContext) -> bool:
    """PRE hook: Check if user exceeds daily limit."""
    key = (context.user_id, context.point_type, datetime.now().date())
    current_total = daily_totals.get(key, Decimal("0"))
    
    # Check limit
    limit = Decimal("1000.00")
    if current_total + context.amount > limit:
        raise HookRejectionError(
            error_code="DAILY_LIMIT_EXCEEDED",
            message=f"Daily transaction limit of ${limit} exceeded",
            details={
                "current_total": float(current_total),
                "requested": float(context.amount),
                "limit": float(limit),
                "remaining": float(limit - current_total),
            }
        )
    
    return True

def record_transaction(context: HookContext) -> None:
    """POST hook: Update daily totals."""
    key = (context.user_id, context.point_type, datetime.now().date())
    current_total = daily_totals.get(key, Decimal("0"))
    daily_totals[key] = current_total + context.amount

def send_notification(context: HookContext) -> None:
    """POST hook: Send email notification."""
    try:
        send_email(
            to=get_user_email(context.user_id),
            subject="Transaction Completed",
            body=f"Your {context.operation} of {context.amount} {context.point_type} was successful"
        )
    except Exception as e:
        # POST hooks should handle their own errors
        logger.error(f"Failed to send notification: {e}")
        raise  # Will be captured in post_hook_errors

# Register hooks with priorities
register_hook("daily_limit", HookType.PRE, check_daily_limit, operation="deduct", priority=50)
register_hook("record_tx", HookType.POST, record_transaction, operation="*", priority=100)
register_hook("notify", HookType.POST, send_notification, operation="*", priority=500)

# Use service normally
service = WalletService(repository)
result = service.deduct_point(
    user_id=123,
    point_type="cash",
    amount=Decimal("500.00"),
    remarks="Purchase"
)

# Handle result
if result.success:
    print(f"Success! Transaction ID: {result.transaction_id}")
    
    if result.post_hook_errors:
        print("Warnings:")
        for err in result.post_hook_errors:
            print(f"  - {err.hook_name}: {err.message}")
else:
    print(f"Failed: {result.error_code}")
    print(f"Message: {result.error_message}")
    if result.error_details:
        print(f"Details: {result.error_details}")
```

### Example Hooks

The package includes 5 production-ready example hooks in `examples/hooks/`:

1. **Daily Limit Checker** (`daily_limit.py`)
   - Track per-user daily transaction limits
   - VIP user support with custom limits
   - Automatic daily reset

2. **Fraud Detection** (`fraud_detection.py`)
   - Detect large amounts, high frequency patterns
   - Identify suspicious amounts (e.g., $9,999.99)
   - Configurable thresholds

3. **Audit Logger** (`audit_logger.py`)
   - Comprehensive transaction logging
   - Separate large transaction logs
   - Query interface for audit trails

4. **Notifications** (`notifications.py`)
   - Multi-channel notifications (email, SMS, push)
   - Low balance alerts
   - Large transaction security alerts

5. **Balance Alerts** (`balance_alert.py`)
   - Graduated alert levels (critical, low, medium)
   - Milestone celebrations
   - Duplicate alert prevention

See `examples/hooks/README.md` for detailed usage and integration examples.

### Best Practices

#### ✅ DO

- **Keep PRE hooks fast** - they block the transaction
- **Use error codes** - follow ALL_CAPS_SNAKE_CASE convention
- **Check immutable conditions** - validate data that won't change
- **Handle POST hook errors** - don't let them crash
- **Use metadata bus** - share expensive calculations
- **Set appropriate priorities** - security first, logging last
- **Test hooks independently** - unit test each hook

#### ❌ DON'T

- **Don't modify context** - all transaction fields are frozen
- **Don't fetch external state in PRE hooks** - may change before execution
- **Don't perform transactions in hooks** - use service for new transactions
- **Don't block in POST hooks** - use async or queues for slow operations
- **Don't ignore POST errors** - log them for debugging
- **Don't register duplicate names** - will raise ValueError

### Disabling Hooks

For testing or specific use cases, disable hooks:

```python
# Disable hooks for this service instance
service = WalletService(repository, enable_hooks=False)

# Returns plain transaction ID (backwards compatible)
transaction_id = service.add_point(
    user_id=123,
    point_type="cash",
    amount=Decimal("100.00"),
    remarks="Deposit"
)
# transaction_id is int, not TransactionResult
```

### For Frontend Developers

If you're building a frontend application (React, Vue, or TypeScript), see our comprehensive **[Frontend Integration Guide](frontend-integration.md)** which includes:

- Complete TypeScript types and interfaces
- React components and custom hooks
- Vue components and composables  
- Error code mapping and UI patterns
- API client implementation examples
- Best practices for handling transaction results and warnings

### Further Reading

- **Design Documentation**: See `docs/hook-system-design.md` for complete design specification
- **Design Guidelines**: See `docs/hook-system-guidelines.md` for reusable design patterns applicable to other projects

## Testing and Development

For comprehensive testing documentation, including test structure, running tests, coverage information, and CI/CD setup, see [docs/testing-wallet-utils.md](testing-wallet-utils.md).

### Quick Start

```bash
# Install in editable mode with test dependencies
pip install -e ".[test]"

# Run migrations
python manage.py migrate

# Run all tests
python manage.py test
```
