Create Custom Contract
Overview Description
The Create Custom Contract feature allows administrators to create custom pricing agreements for groups. This endpoint creates a new custom contract with specified terms including billing amount, interval, and usage limits. The contract can be linked to an existing subscription or a new subscription will be created automatically.
Pre-conditions
- User must be authenticated as admin (SuperAdmin or AdminStaff role)
- Target group must exist and be active
- If linking to existing subscription, it must:
- Belong to the specified group
- Have
pricing_type = 'custom'or be cancelled - Not have an active non-custom subscription
Dependencies
- Group Management Module (for group validation)
- Subscription Module (for subscription creation/linking)
- User Service (for Stripe customer synchronization)
Swagger Link
Case Documentation
Case 1: Successful Contract Creation (New Subscription)
Description
Administrator successfully creates a custom contract for a group that doesn't have an active subscription. A new subscription with pricing_type = 'custom' is automatically created.
Sequence Diagram
sequenceDiagram
participant Admin
participant Controller as CustomContractController
participant Request as StoreCustomContractRequest
participant Service as CustomContractService
participant GroupRepo as GroupRepository
participant SubscriptionRepo as SubscriptionRepository
participant UserService as UserService
participant ContractRepo as CustomContractRepository
participant Database
Note over Admin,Database: POST /api/v1/admin/custom-contracts
rect rgb(200, 255, 200)
Note right of Admin: Happy Case Flow - New Subscription
Admin->>Controller: POST request with contract data
rect rgb(200, 230, 255)
Note right of Controller: Input Validation
Controller->>Request: validate(data)
Request->>Request: Check required fields
Request->>Request: Validate billing_interval
Request->>Request: Validate amount >= 0
Request->>Request: Check code uniqueness
Request-->>Controller: Validation passed
end
rect rgb(200, 255, 255)
Note right of Controller: Business Logic Processing
Controller->>Service: create(validatedData)
Service->>Service: transactionBegin()
Service->>GroupRepo: findById(group_id)
GroupRepo->>Database: SELECT * FROM groups WHERE id = ?
Database-->>GroupRepo: Return group data
GroupRepo-->>Service: Return group object
Service->>Service: Check active subscription
Service->>Service: Verify no active non-custom subscription
Service->>SubscriptionRepo: create(subscription_data)
SubscriptionRepo->>Database: INSERT INTO subscriptions
Database-->>SubscriptionRepo: Return created subscription
SubscriptionRepo-->>Service: Return subscription object
Service->>ContractRepo: create(contract_data)
ContractRepo->>Database: INSERT INTO custom_contracts
Database-->>ContractRepo: Return created contract
ContractRepo-->>Service: Return contract object
Service->>Service: Set contract status to 'draft'
Service->>SubscriptionRepo: update pricing_type and custom_contract_id
SubscriptionRepo->>Database: UPDATE subscriptions
Database-->>SubscriptionRepo: Update confirmed
Service->>Service: transactionCommit()
Service-->>Controller: Return contract with relations
end
Controller-->>Admin: 200 OK (contract_data, success_message)
end
rect rgb(255, 200, 200)
Note right of Admin: Error Scenarios
rect rgb(255, 230, 230)
alt Validation Error
Request-->>Controller: Validation failed
Controller-->>Admin: 422 Unprocessable Entity
else Group Not Found
GroupRepo-->>Service: Group not found
Service->>Service: transactionRollback()
Service-->>Controller: Error: Group not found
Controller-->>Admin: 400 Bad Request
else Active Non-Custom Subscription Exists
Service-->>Service: Check fails
Service->>Service: transactionRollback()
Service-->>Controller: Error: Cannot switch type
Controller-->>Admin: 400 Bad Request
else Database Error
Database-->>ContractRepo: Database constraint error
Service->>Service: transactionRollback()
Service-->>Controller: Error: Database error
Controller-->>Admin: 400 Bad Request
end
end
end
Steps
Step 1: Request Validation
- Description: System validates all input data according to business rules
- Request:
POST /api/v1/admin/custom-contracts - Authorization: User must have SuperAdmin or AdminStaff role
- Validation Rules:
group_id: Required, must exist in groups tablecode: Required, max 100 characters, must be uniquebilling_interval: Required, must be 'month' or 'year'amount: Required, integer, minimum 0currency: Required, default 'jpy', max 10 characterspackage_plan_id: Required if subscription_id not provided, must exist in package_plans tablesubscription_id: Required if package_plan_id not provided, must exist in subscriptions table- Note: Either
subscription_idorpackage_plan_idmust be provided (at least one is required) user_id: Optional, must exist if providedstarts_at: Optional, must be valid dateends_at: Optional, but required if starts_at is provided, must be date after or equal to starts_at- All limit fields (max_member, max_product_group, etc.): Optional, integer, min 0
Step 2: Group Validation
- Description: Verify target group exists and check subscription constraints
- Action:
- Query group by group_id
- Check if group has active subscription
- If active subscription exists, verify it's either:
- Already custom type (
pricing_type = 'custom') - Or cancelled (
status = 'cancelled')
- Already custom type (
- Business Rules:
- Cannot create custom contract for group with active non-custom subscription
- Group must exist and be active
- Potential Errors:
- Group not found (404)
- Active non-custom subscription exists (400)
Step 3: Subscription Creation or Validation
- Description: Create new subscription or validate existing one
- Action for New Subscription:
- Verify package_plan_id is provided
- Get package plan details
- Get user (from user_id or group.created_by)
- Sync Stripe customer ID if missing
- Create subscription record with:
pricing_type = 'custom'status = 'unpaid'- Link to package plan and group
- Action for Existing Subscription:
- Verify subscription belongs to the group
- Check subscription pricing_type is 'custom' or status is 'cancelled'
- Use subscription's package_plan_id if not provided
- Database Operations:
- INSERT INTO subscriptions (for new)
- or SELECT subscription (for existing)
Step 4: Contract Creation
- Description: Create custom contract record with all terms
- Action:
- Insert contract record with:
- Subscription link (subscription_id)
- Group and user links
- Billing terms (code, amount, currency, billing_interval)
- Date range (starts_at, ends_at)
- Usage limits (max_member, max_product_group, etc.)
- Data visibility and API availability settings
- Set initial status to 'draft'
- Insert contract record with:
- Database Operations:
- INSERT INTO custom_contracts
- Set timestamps (created_at, updated_at)
Step 5: Update Subscription
- Description: Link subscription to contract and set pricing type
- Action:
- Update subscription record:
- Set
pricing_type = 'custom' - Set
custom_contract_id = contract.id
- Set
- Commit transaction
- Update subscription record:
- Database Operations:
- UPDATE subscriptions SET pricing_type, custom_contract_id
Step 6: Return Response
- Description: Return created contract with relationships
- Response Data:
- Contract ID and details
- Related subscription data
- Related group data
- Related user data (if applicable)
- Success Message: "カスタムプランが正常に作成されました" (Custom plan created successfully)
Database Related Tables & Fields
erDiagram
custom_contracts {
bigint id PK
bigint subscription_id FK "Link to subscriptions table (nullable)"
bigint package_plan_id FK "Base package plan reference (nullable)"
bigint group_id FK "Group owning this contract"
bigint user_id FK "User responsible for contract (nullable)"
string code "Unique contract identifier (max 100 chars, unique)"
string billing_interval "month or year"
string currency "Currency code (default jpy, max 10 chars)"
integer amount "Billing amount in minor unit"
string status "draft, offered, active, expired, cancelled (default draft)"
string provider_checkout_session_id "Stripe checkout session ID (nullable, max 100)"
string provider_price_id "Stripe price ID (nullable, max 100)"
string provider_subscription_item_id "Stripe subscription item ID (nullable, max 100)"
timestamp starts_at "Contract start date (nullable)"
timestamp ends_at "Contract end date (nullable)"
integer max_member "Maximum members allowed (nullable)"
integer max_product_group "Maximum product groups (nullable)"
integer max_product "Maximum products (nullable)"
integer max_category "Maximum categories (nullable)"
integer max_search_query "Maximum search queries (nullable)"
integer max_viewpoint "Maximum viewpoints (nullable)"
string data_visible "Data visibility setting (nullable)"
tinyint api_available "API availability flag (default 1, nullable)"
timestamp created_at
timestamp updated_at
}
subscriptions {
bigint id PK
string slug "Unique subscription identifier"
bigint package_id FK
bigint package_plan_id FK
bigint group_id FK
bigint user_id FK
string pricing_type "standard or custom"
bigint custom_contract_id FK "Link to custom_contracts (nullable)"
string status "unpaid, active, cancelled, expired"
string payment_provider_customer_id "Stripe customer ID"
string email "Subscription owner email"
timestamp created_at
timestamp updated_at
}
groups {
bigint id PK
string name "Group name"
bigint created_by FK "User who created group"
integer status "Group status (1=active)"
timestamp created_at
timestamp updated_at
}
users {
bigint id PK
string name "User full name"
string email "User email (unique)"
string payment_provider_customer_id "Stripe customer ID (nullable)"
timestamp created_at
timestamp updated_at
}
package_plans {
bigint id PK
bigint package_id FK
string name "Plan name"
string billing_interval "month or year"
integer amount "Plan amount"
timestamp created_at
timestamp updated_at
}
custom_contracts ||--o| subscriptions : has
custom_contracts ||--|| groups : belongs_to
custom_contracts ||--o| users : managed_by
custom_contracts ||--o| package_plans : based_on
subscriptions ||--|| groups : belongs_to
subscriptions ||--|| users : owned_by
Error Handling
- Log: All errors are logged via
logThrow()method with full exception details - Transaction Rollback: Automatic rollback on any error during contract creation
- Error Detail:
| Status Code | Error Message | Description |
|---|---|---|
| 422 | Validation error messages | When input validation fails (see validation rules above) |
| 400 | "事業者が見つかりませんでした" | When specified group doesn't exist |
| 400 | "サブスクリプションのタイプ切り替えは許可されていません" | When group has active non-custom subscription |
| 400 | "サブスクリプションが見つかりませんでした" | When specified subscription_id doesn't exist |
| 400 | "グループとサブスクリプションが一致しません" | When subscription doesn't belong to the group |
| 400 | "アクティブなサブスクリプションが既に存在します" | When trying to create new subscription but group already has active one |
| 400 | "パッケージプランが必要です" | When package_plan_id missing for new subscription |
| 400 | "パッケージプランが見つかりませんでした" | When specified package_plan doesn't exist |
| 400 | "ユーザーが見つかりませんでした" | When specified user_id doesn't exist |
| 400 | Generic error with exception message | For unexpected database or system errors |
Case 2: Successful Contract Creation (Existing Subscription)
Description
Administrator creates a custom contract linked to an existing subscription that already has pricing_type = 'custom'. The subscription is updated with the new contract details.
Key Differences from Case 1
- Subscription already exists with
pricing_type = 'custom' - No new subscription creation needed
- Only contract record is created and linked to existing subscription
- Subscription's
custom_contract_idis updated to new contract
Validation Steps
- Verify subscription exists and belongs to the group
- Check subscription
pricing_type = 'custom'or status is 'cancelled' - Use subscription's package_plan_id if not provided in request
- All other steps follow same flow as Case 1
Additional Notes
Contract Status Lifecycle
- Draft: Initial status after contract creation, before payment link is sent
- Offered: Status after payment link is sent to customer
- Active: Status after first successful payment (via Stripe webhook)
- Expired: Status when contract ends_at date has passed
- Cancelled: Status when subscription is cancelled before expiry
Stripe Customer Synchronization
- If user doesn't have
payment_provider_customer_id, system automatically creates Stripe customer - Customer creation uses user's email and name from database
- Customer ID is stored in both user and subscription records
- Required for future payment link generation
Usage Limits
- All limit fields (max_member, max_product_group, etc.) are optional
nullvalue means unlimited for that resource0value means feature is disabled- Limits are enforced by subscription module during usage
Currency Handling
- Default currency is 'jpy' (Japanese Yen)
- Amount is stored in minor unit (e.g., for JPY: 1000 = ¥1,000)
- Currency code is automatically converted to lowercase
Transaction Management
- Entire operation wrapped in database transaction
- Any failure triggers automatic rollback
- Ensures data consistency across subscriptions and contracts tables
Performance Considerations
- Single database transaction for all operations
- Minimal external API calls (only Stripe customer creation if needed)
- Indexed queries on subscription_id and status fields