Yoyacoo Coupon System Design
Overview
The coupon system enables suppliers to create discount coupons that customers can apply during the reservation flow. Coupons support both fixed-amount and percentage discounts, with flexible targeting rules and usage limits.
Actors:
| Actor | Role |
|---|---|
| Supplier | Creates and manages coupons via /user portal |
| Customer | Applies coupon codes during reservation via /customer portal |
| Admin | Read-only visibility (monitoring, no management UI in scope) |
Scope: Supplier CRUD, customer validation/availability, pricing engine, order snapshot, email breakdown, and FE integration. Stripe subscription coupons are a separate domain (see INTEGRATION.md).
Data Model
Table: coupons
| Column | Type | Description |
|---|---|---|
id |
bigint (PK) | Primary key |
user_id |
FK → users | Supplier who owns the coupon |
name |
string | Coupon display name |
code |
string(20) | Unique code (uppercase + lowercase + digits only) |
discount_type |
string | fixed_amount or percentage |
discount_value |
unsignedInteger | Amount in yen or percentage (1-100) |
starts_at |
date | Validity start date |
ends_at |
date (nullable) | Validity end date (null = no expiry) |
target_type |
string | all_paid_contents_menus or specified |
usage_limit_type |
string | unlimited or limited_per_email |
usage_limit_count |
unsignedInteger (nullable) | Max uses per email (1-10) |
memo |
text (nullable) | Internal supplier notes |
created_at updated_at deleted_at |
timestamps | Standard + SoftDeletes |
Constraints: UNIQUE (user_id, code) — code is unique per supplier only.
Table: coupon_targets
| Column | Type | Description |
|---|---|---|
id |
bigint (PK) | |
coupon_id |
FK → coupons (CASCADE) | |
service_id |
FK → services | |
service_menu_id |
FK → service_menu (legacy, nullable) | |
target_kind |
string(32) (nullable) | See TargetKind enum |
target_id |
unsignedBigInteger (nullable) | ID of the target entity |
created_at updated_at |
timestamps |
Legacy support: The model normalizes from service_menu_id/menu_id legacy columns via normalizedTargetKind() and normalizedTargetId() methods. New records use target_kind + target_id.
TargetKind Enum
| Kind | Description | Example |
|---|---|---|
service |
Entire service (deprecated for new coupons) | All menus of a calendar |
paid_service |
All paid menus/details of a service | “すべてのメニュー” |
service_menu |
A specific calendar menu | “Basic course” |
service_detail |
A specific form/event detail | “Early bird ticket” |
Table: coupon_usages
| Column | Type | Description |
|---|---|---|
id |
bigint (PK) | |
coupon_id |
FK → coupons | |
user_id |
FK → users | Supplier |
customer_id |
FK (nullable) | |
email |
string | Email of the customer |
order_id |
FK → orders (nullable) | |
idempotency_key |
string (nullable, unique) | Prevents double-submit |
discount_amount |
unsignedInteger | Actual discount applied |
used_at |
timestamp | When the coupon was used |
created_at updated_at |
timestamps |
Constraints: UNIQUE (coupon_id, email, order_id) — prevents duplicate usage per order.
Columns on orders
| Column | Type | Description |
|---|---|---|
coupon_id |
FK → coupons (nullable, nullOnDelete) | Applied coupon |
coupon_code |
string(20) (nullable) | Snapshot of code at time of order |
coupon_discount_type |
string(20) (nullable) | Snapshot: fixed_amount / percentage |
coupon_discount_value |
unsignedInteger (nullable) | Snapshot: discount value |
coupon_discount_amount |
unsignedInteger (default 0) | Actual discount applied |
settlement_amount |
unsignedInteger (nullable) | Final settlement amount |
The FK uses nullOnDelete so orders survive coupon deletion while preserving history via snapshot columns.
Entity Relationship
Coupon (user_id → users)
├── hasMany → CouponTarget (coupon_id, service_id → services)
│ target_kind: service | paid_service | service_menu | service_detail
│ target_id: nullable → service_menu.id or service_detail.id
└── hasMany → CouponUsage (coupon_id, email, order_id)
Order
└── belongsTo → Coupon (nullable, nullOnDelete)
+ snapshot fields for audit trail
Business Rules
Discount Types
| Type | Input | Calculation |
|---|---|---|
fixed_amount |
Integer (yen) | discount = min(discount_value, eligible_subtotal) |
percentage |
Integer (1-100) | discount = floor(eligible_subtotal * value / 100) |
The fixed_amount maximum is capped by the supplier’s plan (amout_content from user_plan_config), falling back to the free plan default (300,000 yen).
Target Types
| Type | Meaning | Stored Targets |
|---|---|---|
all_paid_contents_menus |
Auto-applies to all paid menus/details across all supported services | No stored targets; evaluated at runtime |
specified |
Applies only to explicitly selected targets | Up to 20 coupon_targets rows |
Usage Limits
| Type | Description |
|---|---|
unlimited |
No restriction on usage count |
limited_per_email |
Max usage_limit_count (1-10) uses per email address |
Status Calculation
The status accessor on the Coupon model computes active/inactive:
inactiveif soft-deletedinactiveifstarts_atis nullinactiveifnow < starts_at(not yet started)inactiveifnow > ends_at(expired)activeotherwise
Option Billing Rule
Discount applies only to menu/detail targets. Options are always charged in full.
target_subtotal = Σ prices of selected menus/details matching coupon
option_subtotal = Σ prices of selected options (service + menu options)
billing_subtotal = target_subtotal + option_subtotal
eligible_subtotal = portion of menu/detail matching coupon rules (NO options included here for base rule)
discount_amount = computed from eligible_subtotal
final_amount = max(0, billing_subtotal - discount_amount)
Options are included in eligible_subtotal only when they belong to menu/detail targets that the coupon explicitly matches (via eligibleOptionSubtotal).
Service Type Allowlist
Only service types [1, 2, 12] (calendar, form, event-seminar) from coupon_setting.php config are eligible. Only paid services are included in target options.
API Contracts
Supplier API (prefix: /api/user)
All endpoints are scoped to the authenticated supplier’s user_id.
| Method | Path | Description |
|---|---|---|
GET |
/coupons |
List coupons (paginated, filterable by keyword + status) |
POST |
/coupons |
Create a coupon |
GET |
/coupons/{coupon} |
Show coupon detail (includes display_targets, readable_targets) |
PUT |
/coupons/{coupon} |
Update a coupon |
DELETE |
/coupons/{coupon} |
Soft-delete a coupon |
POST |
/coupons/generate-code |
Auto-generate a unique 16-char alphanumeric code |
GET |
/coupons/target-options |
Get eligible target options for the coupon form dropdown |
Create/Update Validation Rules
| Field | Rules |
|---|---|
name |
Required, string, max:255 |
code |
Required, regex /^[A-Za-z0-9]+$/, max:20, unique per supplier |
discount_type |
Required, in:fixed_amount,percentage |
discount_value |
Required, integer, min:1, max: plan limit (percentage: 100) |
starts_at |
Required, date |
ends_at |
Nullable, date, after_or_equal:starts_at |
target_type |
Required, in:all_paid_contents_menus,specified |
usage_limit_type |
Required, in:unlimited,limited_per_email |
usage_limit_count |
Required if limited_per_email, integer, min:1, max:10 |
targets |
Required if specified, array, min:1, max:20 |
targets.*.service_id |
Required, must belong to supplier |
targets.*.target_kind |
Required, in TargetKind values |
targets.*.target_id |
Nullable (required for menu/detail, must exist) |
memo |
Nullable, string |
Customer API (prefix: /api/customer)
POST /coupons/availability
Request:
{
"supplier_id": 1,
"service_id": 5,
"service_menu_id": 10,
"service_detail_ids": [1, 2]
}
Response:
{
"has_active_coupon": true
}
Used by the FE to decide whether to show the coupon input field on the reservation form.
POST /coupons/validate
Request:
{
"code": "ABC123",
"supplier_id": 1,
"service_id": 5,
"subtotal": 5000,
"email": "customer@example.com",
"service_menu_id": 10,
"service_menu_option_ids": [1, 2],
"service_option_ids": [3],
"service_detail_ids": [1],
"service_details": [{ "id": 1, "quantity": 2 }]
}
Success response (200):
{
"valid": true,
"coupon_id": 1,
"code": "ABC123",
"target_subtotal": 4000,
"option_subtotal": 1000,
"eligible_subtotal": 4000,
"discount_amount": 500,
"final_amount": 4500,
"credit_settlement_amount": 4500,
"label": "500円OFF"
}
Error response (400):
{
"error": "invalid_or_expired_code",
"message": "Bad Request",
"errors": [
{
"code": "invalid_or_expired_code",
"message": "入力したクーポンコードは有効ではありません。"
}
]
}
Error codes: invalid_or_expired_code, usage_limit_exceeded, coupon_not_applicable.
Pricing Engine
The CouponPricingService performs the following steps when validating a coupon:
1. Validate Coupon
- Look up coupon by
user_id+code - Check
status === 'active'(date range, not deleted) - If
limited_per_email, count usages and reject if exceeded
2. Build Target Context
targetContextFromInput() extracts targets from the request:
service_details[](with quantities) →SERVICE_DETAILentriesservice_detail_ids[]→SERVICE_DETAILentries (qty=1)service_menu_id/menu_id→SERVICE_MENUorSERVICE_DETAIL(based on service type)target_kind+target_id→ explicit entry- Fallback:
SERVICElevel
3. Match Targets
couponMatchesAnyTarget() checks if the coupon applies:
- For
all_paid_contents_menus: any paid target in context suffices - For
specified: at least oneCouponTargetmust match a context entry
4. Calculate Subtotals
target_subtotal = Σ (price × quantity) from DB for menus/details in target context
option_subtotal = Σ service_option prices + Σ service_menu_option prices (from DB)
billing_subtotal = target_subtotal + option_subtotal
5. Calculate Eligible Subtotal
For all_paid_contents_menus: entire target_subtotal (all paid targets).
For specified: only the portion of target_subtotal that matches coupon targets.
Option subtotal may be partially included in eligible_subtotal if the matched target’s kind triggers option eligibility (e.g., SERVICE_DETAIL includes service options).
6. Calculate Discount
discount_amount = fixed: min(discount_value, eligible_subtotal)
= percentage: floor(eligible_subtotal × discount_value / 100)
7. Final Amount
final_amount = max(0, billing_subtotal - discount_amount)
8. Credit Settlement Amount
credit_settlement_amount = 0 if final_amount == 0
= max(300, final_amount) if final_amount > 0
Credit card payments cannot be below 300 yen. If the discount brings the total under 300, it floors at 300. If the total hits exactly 0, credit settlement is 0 (free).
Frontend Architecture
Supplier Frontend (yoyacoo_fe/supplier)
Routes (Pages Router):
| Route | Page |
|---|---|
/promotion/coupons |
Coupon list (table, search, status filter, pagination) |
/promotion/coupons/create |
Create coupon form |
/promotion/coupons/[id] |
Coupon detail (with delete modal) |
/promotion/coupons/[id]/edit |
Edit coupon form |
Navigation:
- Header:
販促betweenメール配信and売上 - Sidebar:
クーポン管理under promotion section
Key Components:
| Component | Purpose |
|---|---|
CouponList.tsx |
Table with search keyword, status filter, pagination |
CouponForm.tsx |
Create/edit form (Formik + Yup, ~700 lines). Handles: auto-generate code, fixed/percentage toggle, date range, target selector with paid_service expansion, usage limit |
CouponDetail.tsx |
Read-only view with status badge, display targets, delete modal |
CouponStatusBadge.tsx |
Green 有効 / gray 無効 badge |
CouponSearchModal.tsx |
Coupon search modal |
API Client (src/apis/promotion/couponApi.ts):
- TypeScript interfaces:
Coupon,CouponTarget,TargetOption,CreateCouponRequest,UpdateCouponRequest - Methods:
indexCoupon,getCoupon,createCoupon,updateCoupon,deleteCoupon,generateCode,getCouponTargets - Legacy data normalization for target fields
Customer Frontend (yoyacoo_fe/customer)
Coupon Flow:
1. Reservation form loads → availability API call
2. If has_active_coupon → show coupon input field
3. User enters code → validate API call
4. Success → update payment breakdown UI (show discount)
5. User proceeds to confirm → re-validate coupon
6. User proceeds to payment → backend finalizes usage
7. User can cancel applied coupon → clears discount
Key Components:
| Component | Purpose |
|---|---|
CouponCodeField.tsx |
Input field with two states: enter code (input + 適用 button) and applied (code display + 取消 button) |
State Management (states/service-form/):
| File | Purpose |
|---|---|
coupon.ts |
buildCouponAppliedPayment, buildCouponClearedPayment, isCouponSupportedService |
types.ts |
FormPayment type with coupon fields |
selectors.ts |
Coupon-related Jotai selectors |
pricing.ts |
buildCouponContextKey for revalidation tracking |
Auto-removal: When reservation selections change, the applied coupon is automatically cleared with message 選択内容が変更されたため、適用中のクーポンを解除しました。
Edge Cases & Error Handling
Legacy Data Support
CouponTargetsupports both old columns (service_menu_id,menu_id) and new (target_kind,target_id)couponApi.tson FE performs normalize logic for legacy format- Customer API accepts both
menu_idandservice_menu_idparams
Soft Delete
- Coupons use SoftDeletes — deleted coupons are hidden but data is preserved
- Status accessor returns
inactivefor trashed coupons - Order FK uses
nullOnDeleteto preserve order history
Coupon Expiry Mid-Order
- Coupon is re-validated at each step (form → confirm → payment)
- If a coupon expires between form view and payment, the re-validation will fail
- Frontend handles this via
couponContextKeychange detection
Idempotency
coupon_usageshas a uniqueidempotency_keycolumn to prevent double-submit
Plan Limit Enforcement
fixed_amountmax value is capped by the supplier’s plan config- Free plan fallback: 300,000 yen
Target Validation
target_type: specifiedrequires at least 1 target, max 20- Service-level targets (
service,paid_service) must not specify atarget_id - Menu/detail targets require a valid existing target in the DB belonging to the supplier