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:

  • inactive if soft-deleted
  • inactive if starts_at is null
  • inactive if now < starts_at (not yet started)
  • inactive if now > ends_at (expired)
  • active otherwise

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_DETAIL entries
  • service_detail_ids[]SERVICE_DETAIL entries (qty=1)
  • service_menu_id / menu_idSERVICE_MENU or SERVICE_DETAIL (based on service type)
  • target_kind + target_id → explicit entry
  • Fallback: SERVICE level

3. Match Targets

couponMatchesAnyTarget() checks if the coupon applies:

  • For all_paid_contents_menus: any paid target in context suffices
  • For specified: at least one CouponTarget must 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

  • CouponTarget supports both old columns (service_menu_id, menu_id) and new (target_kind, target_id)
  • couponApi.ts on FE performs normalize logic for legacy format
  • Customer API accepts both menu_id and service_menu_id params

Soft Delete

  • Coupons use SoftDeletes — deleted coupons are hidden but data is preserved
  • Status accessor returns inactive for trashed coupons
  • Order FK uses nullOnDelete to 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 couponContextKey change detection

Idempotency

  • coupon_usages has a unique idempotency_key column to prevent double-submit

Plan Limit Enforcement

  • fixed_amount max value is capped by the supplier’s plan config
  • Free plan fallback: 300,000 yen

Target Validation

  • target_type: specified requires at least 1 target, max 20
  • Service-level targets (service, paid_service) must not specify a target_id
  • Menu/detail targets require a valid existing target in the DB belonging to the supplier