Customer-to-User Subscription (#956) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Complete the WIP customer-to-user subscription feature: fix the Stripe Connect payment bug, add missing webhook handlers, and implement the customer-facing subscription flow pages (select → payment → complete).

Architecture: Stripe Connect Destination Charges for subscriptions — Platform creates the Stripe Subscription using the Connect API with transfer_data[destination] routing funds to the user’s connected Stripe account. The customer frontend mirrors the event seminar flow: LP → select menu → payment → complete.

Tech Stack: Laravel 10 (BE), Next.js 12 (Customer FE), Stripe Connect, Jotai, Tailwind CSS, Formik.


Current State Analysis

What IS already implemented

Backend (feat/956_service_subscriptions):

  • ✅ Migrations: payment_cycle on services, subscription fields on service_menu (trial_period_, contract_period_), stripe_product_id on services, stripe_price_id on service_menu, stripe_customer_id on users
  • StripeSubscriptionSync trait — syncs Stripe products and recurring prices when user creates/edits a subscription service
  • ✅ Supplier (user app): CreateSubscriptionInteractor, UpdateSubscriptionInteractor, SubscriptionMenuInteractor
  • ✅ Customer: OrderServiceTypeInteractor/SubscriptionInteractor — creates an Order record (no payment)
  • ✅ Customer: PaymentServiceTypeInteractor/SubscriptionInteractor — calls Stripe BUT USES WRONG KEY (see Task 1)
  • ✅ Customer subscription management: cancel, payment-histories, payment-method endpoints
  • ✅ Admin ConnectWebhookController — handles invoice.payment_succeeded, invoice.payment_failed

Frontend (feat/redmine_956):

  • ✅ Supplier: full SubscriptionServiceForm.tsx (create/edit)
  • ✅ Customer: SubscriptionServiceInfo / SubscriptionServiceDetail types in service-form/types.ts
  • ✅ Customer: useFetchServiceForm handles subscription service type
  • ✅ Customer LP page: template blocks handle "menu.subscription" case in EditorDisplay
  • ✅ Customer: stub pages at subscription/[uuid]/select.tsx, payment.tsx, complete.tsx

What is MISSING (this plan)

Backend:

  1. Critical bug: PaymentServiceTypeInteractor/SubscriptionInteractor.php uses platform’s stripe.payment.secret and creates a fresh Stripe Customer each time — must use Connect API with transfer_data[destination] to route funds to the user’s connected account
  2. Missing webhook: customer.subscription.deleted — order should be marked canceled when subscription is deleted in Stripe

Frontend (Customer):
3. LP page routes subscription services to /form/{form_uuid} — must route to /subscription/{form_uuid}/select
4. subscription/[uuid]/select.tsx — menu selection + customer info form (stub → real)
5. subscription/[uuid]/payment.tsx — Stripe card payment (stub → real)
6. subscription/[uuid]/complete.tsx — success page (stub → real)


File Map

File Action Responsibility
yoyacoo_be/customer/app/Domains/Reservation/Usecase/PaymentServiceTypeInteractor/SubscriptionInteractor.php Modify Fix Connect subscription creation
yoyacoo_be/admin/app/Domains/Subscription/Controllers/ConnectWebhookController.php Modify Add handleCustomerSubscriptionDeleted
yoyacoo_fe/customer/src/pages/lp/[uuid]/index.tsx Modify Route subscription to /subscription/ path
yoyacoo_fe/customer/src/pages/subscription/[uuid]/select.tsx Modify (stub→real) Menu selection + customer info form
yoyacoo_fe/customer/src/pages/subscription/[uuid]/payment.tsx Modify (stub→real) Stripe card payment
yoyacoo_fe/customer/src/pages/subscription/[uuid]/complete.tsx Modify (stub→real) Success / completion page

Task 0: FE — Update Swagger Spec and Regenerate Customer Client

The CustomerReservationServiceDto in swagger/api/customer/components/_reservation.yaml only has id, service_detail_ids, and service_option_ids. The subscription order and payment endpoints need service_menu_id and order_uuid in the service object — these fields must be added to the spec and the TypeScript client must be regenerated.

Files:

  • Modify: yoyacoo_fe/swagger/api/customer/components/_reservation.yaml

  • Regenerate: yoyacoo_fe/customer/src/apis/clients/ (auto-generated — do not edit directly)

  • Step 1: Add service_menu_id and order_uuid to CustomerReservationServiceDto

In yoyacoo_fe/swagger/api/customer/components/_reservation.yaml, find:

CustomerReservationServiceDto:
  type: object
  description: "予約申込登録(サービス)"
  properties:
    id:
      ...
    service_detail_ids:
      ...
    service_option_ids:
      ...
  required:
    - id
    - service_detail_ids

Add two new optional properties after service_option_ids:

    service_menu_id:
      type: integer
      format: int64
      example: 1
      description: "サービスメニューID(service_menu.id) — used for subscription services"
    order_uuid:
      type: string
      example: "abc123"
      description: "注文UUID(orders.uuid) — used to link payment to existing order"
  • Step 2: Regenerate the customer API client
cd /var/www/yoyacoo_fe && ./crage codegen:customer

Expected: client files regenerated in customer/src/apis/clients/

  • Step 3: Verify the new fields appear in the generated client
grep -n "service_menu_id\|order_uuid" yoyacoo_fe/customer/src/apis/clients/api.ts | grep CustomerReservation | head -5
  • Step 4: Typecheck
cd yoyacoo_fe/customer && yarn typecheck 2>&1 | tail -20
  • Step 5: Commit
cd yoyacoo_fe
git add swagger/api/customer/components/_reservation.yaml customer/src/apis/clients/
git commit -m "feat(swagger): add service_menu_id and order_uuid to CustomerReservationServiceDto"

Task 1: Fix BE — Stripe Connect Subscription Payment

Problem: PaymentServiceTypeInteractor/SubscriptionInteractor.php uses StripePaymentSubscription (platform API key) and creates a new Stripe customer each time. It must use Stripe Connect (Connect API key) with transfer_data[destination] pointing to the seller’s stripe_connect_id, matching how EventSeminarInteractor works.

File: yoyacoo_be/customer/app/Domains/Reservation/Usecase/PaymentServiceTypeInteractor/SubscriptionInteractor.php

  • Step 1: Read the current file
cat yoyacoo_be/customer/app/Domains/Reservation/Usecase/PaymentServiceTypeInteractor/SubscriptionInteractor.php
  • Step 2: Replace the file with a Connect-based implementation
<?php declare(strict_types=1);

namespace App\Domains\Reservation\Usecase\PaymentServiceTypeInteractor;

use App\Domains\Reservation\Usecase\Interface\PaymentInterface;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Cashier;
use ReserveApp\Common\Base\Traits\StripeConnectCustomer;
use ReserveApp\Common\Base\Traits\StripeConnectPayment;
use ReserveApp\Common\Enums\Service\PaymentCycle;
use ReserveApp\Common\Models\Order;
use ReserveApp\Common\Models\Service;
use ReserveApp\Common\Models\ServiceMenu;
use ReserveApp\Common\Models\User;
use Stripe\Subscription;
use Symfony\Component\HttpFoundation\Response as ResponseAlias;
use Throwable;

class SubscriptionInteractor
{
    use StripeConnectPayment;
    use StripeConnectCustomer;

    public function handle(PaymentInterface $interface): array
    {
        $requestService  = $interface->getService();
        $requestCustomer = $interface->getCustomer();
        $paymentMethodId = $interface->getPaymentMethodId();

        $serviceModel = Service::findOrFail($requestService['id']);
        $menuModel    = ServiceMenu::where('id', $requestService['service_menu_id'])
            ->where('service_id', $serviceModel->id)
            ->firstOrFail();

        if (empty($menuModel->stripe_price_id)) {
            throw new Exception(__('Stripe price does not exist for this menu.'));
        }

        $userModel = User::with(['userPaymentSetting'])->findOrFail($serviceModel->user_id);

        if (empty($userModel->userPaymentSetting?->stripe_connect_id)) {
            throw new Exception(__('Connect account does not exist.'));
        }

        $destinationAccountId = $userModel->userPaymentSetting->stripe_connect_id;
        $feePercent           = (float) $userModel->userPlanConfig['percentage_fee_card'];

        try {
            $name           = trim(($requestCustomer['last_name'] ?? '') . ' ' . ($requestCustomer['first_name'] ?? ''));
            $customerParams = [
                'name'  => substr($name, 0, 255),
                'email' => $requestCustomer['email'],
            ];
            // Reuse existing Stripe customer by email or payment method attachment
            $stripeCustomerId = $this->getCustomerIdByPaymentMethod($paymentMethodId, $customerParams);

            $trialEnd = 0;
            if (!empty($menuModel->trial_period_flag) && $menuModel->trial_period_days > 0) {
                $trialEnd = now()->addDays($menuModel->trial_period_days)->timestamp;
            }

            $subscriptionParams = [
                'customer'             => $stripeCustomerId,
                'items'                => [['price' => $menuModel->stripe_price_id]],
                'transfer_data'        => ['destination' => $destinationAccountId],
                'application_fee_percent' => $feePercent,
                'default_payment_method'  => $paymentMethodId,
                'payment_behavior'     => 'default_incomplete',
                'payment_settings'     => ['save_default_payment_method' => 'on_subscription'],
                'expand'               => ['latest_invoice.payment_intent'],
            ];

            if ($trialEnd) {
                $subscriptionParams['trial_end'] = $trialEnd;
            }

            if ($serviceModel->payment_cycle === PaymentCycle::MONTHLY_FROM_FIRST_DAY->value) {
                $anchorBase = $trialEnd ? Carbon::createFromTimestamp($trialEnd) : now();
                $subscriptionParams['billing_cycle_anchor'] = $anchorBase->addMonthNoOverflow()->startOfMonth()->timestamp;
                $subscriptionParams['proration_behavior']   = 'create_prorations';
            }

            $subscription = Subscription::create(
                $subscriptionParams,
                $this->stripeConnectOptions()
            );

            Log::info('Connect subscription created', [
                'subscription_id' => $subscription->id,
                'service_id'      => $serviceModel->id,
                'destination'     => $destinationAccountId,
            ]);

            if (!empty($requestService['order_uuid'])) {
                Order::where('uuid', $requestService['order_uuid'])
                    ->update(['stripe_payment_id' => $subscription->id]);
            }

            $paymentIntent = $subscription->latest_invoice->payment_intent ?? null;

            if ($paymentIntent) {
                return [$paymentIntent, ResponseAlias::HTTP_OK];
            }

            // Trial subscriptions have no immediate payment intent
            $result                = new \stdClass();
            $result->client_secret = null;
            return [$result, ResponseAlias::HTTP_OK];

        } catch (Throwable $e) {
            throw new Exception($e->getMessage());
        }
    }
}
  • Step 3: Verify the file saves correctly
php -l yoyacoo_be/customer/app/Domains/Reservation/Usecase/PaymentServiceTypeInteractor/SubscriptionInteractor.php

Expected: No syntax errors detected

  • Step 4: Run PHP CS Fixer
cd yoyacoo_be/customer && ./vendor/bin/php-cs-fixer fix app/Domains/Reservation/Usecase/PaymentServiceTypeInteractor/SubscriptionInteractor.php
  • Step 5: Commit
cd yoyacoo_be
git add customer/app/Domains/Reservation/Usecase/PaymentServiceTypeInteractor/SubscriptionInteractor.php
git commit -m "fix(subscription): use Stripe Connect with transfer_data for customer subscriptions"

Task 2: BE — Add customer.subscription.deleted Webhook Handler

When a customer’s Stripe subscription is deleted (e.g., manually canceled from Stripe dashboard or expires), mark the associated order as CANCEL.

File: yoyacoo_be/admin/app/Domains/Subscription/Controllers/ConnectWebhookController.php

  • Step 1: Read the current ConnectWebhookController
cat yoyacoo_be/admin/app/Domains/Subscription/Controllers/ConnectWebhookController.php
  • Step 2: Add handleCustomerSubscriptionDeleted after the existing invoice handlers

Add this method inside the class, after handleInvoicePaymentFailed:

/**
 * Handle customer.subscription.deleted
 * Mark order as canceled when Stripe subscription is deleted
 *
 * @param array $payload
 * @return \Symfony\Component\HttpFoundation\Response
 */
protected function handleCustomerSubscriptionDeleted(array $payload)
{
    $subscription   = $payload['data']['object'];
    $subscriptionId = $subscription['id'] ?? null;

    if ($subscriptionId) {
        $orderModel = Order::where('stripe_payment_id', $subscriptionId)->first();
        if ($orderModel && $orderModel->customerPurchaseHistory->service->isSubscription()) {
            $orderModel->status      = OrderStatus::CANCEL->value;
            $orderModel->canceled_at = now();
            $orderModel->save();
        }
    }

    Logger::Stripe(__('Webhook[customer.subscription.deleted]'), $subscription);
    return $this->successMethod();
}
  • Step 3: Verify syntax
php -l yoyacoo_be/admin/app/Domains/Subscription/Controllers/ConnectWebhookController.php

Expected: No syntax errors detected

  • Step 4: Run CS Fixer
cd yoyacoo_be/admin && ./vendor/bin/php-cs-fixer fix app/Domains/Subscription/Controllers/ConnectWebhookController.php
  • Step 5: Commit
cd yoyacoo_be
git add admin/app/Domains/Subscription/Controllers/ConnectWebhookController.php
git commit -m "feat(webhook): handle customer.subscription.deleted to cancel order"

Task 3: FE Customer — LP Page Routes Subscription to /subscription/

The LP page currently routes all services to /form/{form_uuid}?.... For subscription services, it must route to /subscription/{form_uuid}/select.

File: yoyacoo_fe/customer/src/pages/lp/[uuid]/index.tsx

Logic: After fetchServiceForm runs, serviceFormAtom.formData.service.type is "subscription" for subscription services. Use this to compute a different formPath.

  • Step 1: Read the current LP page file (relevant section)

Lines 113–160 of yoyacoo_fe/customer/src/pages/lp/[uuid]/index.tsx

  • Step 2: Update editorDisplayOptions to detect subscription type

Find this block (around line 131):

  const editorDisplayOptions: EditorDisplayOptions = useMemo(() => {
    const formPath =
      formUuid && serviceViewType === ServiceViewType.BUTTON
        ? `/form/${formUuid}?lpId=${uuid}&from=${ServiceViewType.BUTTON}`
        : "";

    const confirmPath = formUuid
      ? `/form/${formUuid}/confirm?lpId=${uuid}&from=${ServiceViewType.FORM}`
      : "";

Replace with:

  const editorDisplayOptions: EditorDisplayOptions = useMemo(() => {
    const isSubscriptionService = formDataService?.type === "subscription";

    const formPath =
      formUuid && serviceViewType === ServiceViewType.BUTTON
        ? isSubscriptionService
          ? `/subscription/${formUuid}/select?lpId=${uuid}`
          : `/form/${formUuid}?lpId=${uuid}&from=${ServiceViewType.BUTTON}`
        : "";

    const confirmPath =
      formUuid && !isSubscriptionService
        ? `/form/${formUuid}/confirm?lpId=${uuid}&from=${ServiceViewType.FORM}`
        : "";
  • Step 3: Add formDataService to the useMemo dependency array

Find the closing of that useMemo:

  }, [formUuid, serviceViewType, uuid]);

Replace with:

  }, [formUuid, serviceViewType, uuid, formDataService?.type]);
  • Step 4: Verify TypeScript
cd yoyacoo_fe/customer && yarn typecheck 2>&1 | tail -20

Expected: no new errors related to lp/[uuid]/index.tsx

  • Step 5: Run linter
cd yoyacoo_fe/customer && yarn analyse 2>&1 | tail -20
  • Step 6: Commit
cd yoyacoo_fe
git add customer/src/pages/lp/[uuid]/index.tsx
git commit -m "feat(lp): route subscription services to /subscription/ flow"

Task 4: FE Customer — Subscription Select Page

The select page shows the subscription menus for the service, lets the customer pick one and fill in contact info (name, email, phone), then creates an order via PUT /reservation/order and navigates to the payment page.

Flow:

  1. Page loads → reads formUuid from URL [uuid]
  2. Calls useFetchServiceForm(formUuid) to populate serviceFormAtom with subscription service data
  3. Shows subscription menus (name, price, trial info, contract period)
  4. Customer selects a menu + enters name / email / phone
  5. On submit → PUT /reservation/order → on success → router push to /subscription/{uuid}/payment?orderUuid={order.uuid}&serviceMenuId={menuId}

File: yoyacoo_fe/customer/src/pages/subscription/[uuid]/select.tsx

Reference: Model after customer/src/pages/form/[uuid]/select.tsx structure, but simplified for subscription menus (no calendar, no hold-dates).

  • Step 1: Read existing reservation API wrapper to understand order endpoint
cat yoyacoo_fe/customer/src/apis/reservation/reservationApi.ts
  • Step 2: Read service-form selectors to understand what data is available
grep -n "useServiceInfo\|useServiceFormUiState\|useServiceFormAtom" yoyacoo_fe/customer/src/states/service-form/selectors.ts | head -30
  • Step 3: Write the select page

Replace the contents of yoyacoo_fe/customer/src/pages/subscription/[uuid]/select.tsx with:

import { NextPageWithLayout } from "next";
import { useRouter } from "next/router";
import React, {
  ReactElement,
  useCallback,
  useEffect,
  useMemo,
  useState,
} from "react";

import { UserPlan } from "@/apis/clients";
import { ApiResponseError } from "@/apis/errors";
import reservationApi from "@/apis/reservation/reservationApi";
import { Button } from "@/components/atoms/buttons/Button";
import { InputText } from "@/components/atoms/inputForms/InputText";
import { AdvertisementFooter } from "@/components/organisms/footer/AdvertisementFooter";
import StepHeaderLayout from "@/components/templates/layout/StepHeader";
import useApiResponseErrorHandler from "@/hooks/useApiResponseErrorHandler";
import { useLoading } from "@/states/loading/operations";
import { useFetchServiceForm } from "@/states/service-form/operations";
import {
  useServiceFormAtom,
  useServiceFormUiState,
} from "@/states/service-form/selectors";
import {
  SubscriptionServiceDetail,
  SubscriptionServiceInfo,
} from "@/states/service-form/types";
import { formatNumber } from "@/utils/number";

const SubscriptionSelectPage: NextPageWithLayout = () => {
  const router = useRouter();
  const { uuid } = router.query;
  const formUuid = typeof uuid === "string" ? uuid : undefined;

  const withLoading = useLoading();
  const { handlePageError } = useApiResponseErrorHandler();
  const fetchServiceForm = useFetchServiceForm();
  const serviceFormAtom = useServiceFormAtom();
  const serviceFormUiState = useServiceFormUiState();

  const serviceInfo = serviceFormAtom.formData.service as
    | SubscriptionServiceInfo
    | undefined;

  const [selectedMenuId, setSelectedMenuId] = useState<number | undefined>(
    undefined,
  );
  const [lastName, setLastName] = useState("");
  const [firstName, setFirstName] = useState("");
  const [email, setEmail] = useState("");
  const [tel, setTel] = useState("");
  const [formError, setFormError] = useState<string | undefined>(undefined);

  const lpId = useMemo(() => {
    const { lpId } = router.query;
    return typeof lpId === "string" ? lpId : undefined;
  }, [router.query]);

  useEffect(() => {
    if (!formUuid) return;
    void withLoading(async () => {
      try {
        await fetchServiceForm(formUuid);
      } catch (e) {
        if (e instanceof ApiResponseError) {
          await handlePageError(e);
        }
        throw e;
      }
    });
  }, [formUuid, fetchServiceForm, handlePageError, withLoading]);

  const menus: SubscriptionServiceDetail[] = useMemo(() => {
    return serviceInfo?.type === "subscription" ? serviceInfo.details : [];
  }, [serviceInfo]);

  const handleSubmit = useCallback(async () => {
    if (!selectedMenuId) {
      setFormError("メニューを選択してください");
      return;
    }
    if (!lastName || !firstName) {
      setFormError("お名前を入力してください");
      return;
    }
    if (!email) {
      setFormError("メールアドレスを入力してください");
      return;
    }
    setFormError(undefined);

        await withLoading(async () => {
      try {
        const result = await reservationApi.putReservationOrder({
          putReservationOrderRequest: {
            service: {
              id: serviceInfo!.id,
              service_detail_ids: [],
              service_menu_id: selectedMenuId,
            },
            customer: {
              last_name: lastName,
              first_name: firstName,
              email,
              tel: tel || undefined,
            },
            page_id: lpId ? Number(lpId) : undefined,
          },
        });
        const orderUuid = result.data.uuid;
        void router.push(
          `/subscription/${formUuid}/payment?orderUuid=${orderUuid}&serviceMenuId=${selectedMenuId}`,
        );
      } catch (e) {
        if (e instanceof ApiResponseError) {
          await handlePageError(e);
        } else {
          setFormError("エラーが発生しました。もう一度お試しください。");
        }
      }
    });
  }, [
    selectedMenuId,
    lastName,
    firstName,
    email,
    tel,
    serviceInfo,
    lpId,
    formUuid,
    router,
    withLoading,
    handlePageError,
  ]);

  if (serviceFormUiState.status === "Loading") {
    return <div className="flex justify-center py-16">読み込み中...</div>;
  }

  return (
    <div className="min-h-screen bg-gray-50">
      <div className="max-w-xl mx-auto px-4 py-8">
        <h1 className="text-xl font-bold text-primary mb-6">
          {serviceInfo?.name ?? "サブスクリプション申込"}
        </h1>

        {/* Menu selection */}
        <section className="mb-8">
          <h2 className="text-base font-bold mb-3">プランを選択してください</h2>
          <ul className="space-y-3">
            {menus.map((menu) => (
              <li key={menu.id}>
                <button
                  type="button"
                  onClick={() => setSelectedMenuId(menu.id)}
                  className={`w-full text-left border rounded-lg p-4 transition-colors ${
                    selectedMenuId === menu.id
                      ? "border-primary bg-primary/5"
                      : "border-gray-200 bg-white hover:border-primary/50"
                  }`}
                >
                  <div className="font-bold text-primary">{menu.name}</div>
                  <div className="text-lg font-bold mt-1">
                    ¥{formatNumber(menu.price)}<span className="text-sm font-normal text-gray-500">/</span>
                  </div>
                  {menu.isSetFreeTrialPeriod && menu.freeTrialPeriod && (
                    <div className="text-sm text-green-600 mt-1">
                      {menu.freeTrialPeriod}日間無料トライアル
                    </div>
                  )}
                  {menu.isSetContractPeriod && menu.contractPeriod && (
                    <div className="text-sm text-gray-500 mt-1">
                      契約期間: {menu.contractPeriod}ヶ月
                    </div>
                  )}
                  {menu.description && (
                    <p className="text-sm text-gray-600 mt-2">{menu.description}</p>
                  )}
                </button>
              </li>
            ))}
          </ul>
        </section>

        {/* Customer info */}
        <section className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
          <h2 className="text-base font-bold mb-4">お客様情報</h2>
          <div className="space-y-4">
            <div className="grid grid-cols-2 gap-3">
              <div>
                <label className="block text-sm font-medium text-gray-700 mb-1"><span className="text-red-500">*</span>
                </label>
                <InputText
                  value={lastName}
                  onChange={(e) => setLastName(e.target.value)}
                  placeholder="山田"
                />
              </div>
              <div>
                <label className="block text-sm font-medium text-gray-700 mb-1"><span className="text-red-500">*</span>
                </label>
                <InputText
                  value={firstName}
                  onChange={(e) => setFirstName(e.target.value)}
                  placeholder="太郎"
                />
              </div>
            </div>
            <div>
              <label className="block text-sm font-medium text-gray-700 mb-1">
                メールアドレス <span className="text-red-500">*</span>
              </label>
              <InputText
                type="email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                placeholder="example@email.com"
              />
            </div>
            <div>
              <label className="block text-sm font-medium text-gray-700 mb-1">
                電話番号
              </label>
              <InputText
                type="tel"
                value={tel}
                onChange={(e) => setTel(e.target.value)}
                placeholder="09012345678"
              />
            </div>
          </div>
        </section>

        {formError && (
          <p className="text-red-500 text-sm mb-4">{formError}</p>
        )}

        <Button
          type="button"
          onClick={() => void handleSubmit()}
          className="w-full"
          disabled={serviceFormUiState.status === "Loading"}
        >
          お支払いへ進む
        </Button>
      </div>
      <AdvertisementFooter plan={serviceInfo?.plan ?? UserPlan.FREE} />
    </div>
  );
};

SubscriptionSelectPage.getLayout = (page: ReactElement) => page;

export default SubscriptionSelectPage;
  • Step 4: Verify TypeScript
cd yoyacoo_fe/customer && yarn typecheck 2>&1 | grep "subscription/\[uuid\]/select" | head -10
  • Step 5: Run linter
cd yoyacoo_fe/customer && yarn analyse 2>&1 | tail -20
  • Step 6: Commit
cd yoyacoo_fe
git add customer/src/pages/subscription/[uuid]/select.tsx
git commit -m "feat(subscription): implement customer subscription select page"

Task 5: FE Customer — Subscription Payment Page

The payment page collects the customer’s credit card and calls POST /reservation/payment with the Stripe payment_method_id. On success it navigates to the complete page.

Query params received from select page:

  • orderUuid — the UUID of the order created in Task 4
  • serviceMenuId — the selected menu ID

File: yoyacoo_fe/customer/src/pages/subscription/[uuid]/payment.tsx

Reference: Model the Stripe card element integration after customer/src/pages/form/[uuid]/payment.tsx (lines 72-160 for Stripe setup, and the payment submission block).

  • Step 1: Read the existing form payment page Stripe integration
sed -n '72,200p' yoyacoo_fe/customer/src/pages/form/[uuid]/payment.tsx
  • Step 2: Read the reservation payment API wrapper
grep -n "postPayment\|payment" yoyacoo_fe/customer/src/apis/reservation/reservationApi.ts | head -20
  • Step 3: Write the payment page

Replace the contents of yoyacoo_fe/customer/src/pages/subscription/[uuid]/payment.tsx with:

import {
  CardCvcElement,
  CardExpiryElement,
  CardNumberElement,
  Elements,
  useElements,
  useStripe,
} from "@stripe/react-stripe-js";
import {
  Appearance,
  StripeCardCvcElementChangeEvent,
  StripeCardExpiryElementChangeEvent,
  StripeCardNumberElementChangeEvent,
  StripeElementsOptions,
  loadStripe,
} from "@stripe/stripe-js";
import { NextPageWithLayout } from "next";
import { useRouter } from "next/router";
import React, {
  ReactElement,
  useCallback,
  useMemo,
  useState,
} from "react";

import { ApiResponseError } from "@/apis/errors";
import reservationApi from "@/apis/reservation/reservationApi";
import { Button } from "@/components/atoms/buttons/Button";
import { AdvertisementFooter } from "@/components/organisms/footer/AdvertisementFooter";
import useApiResponseErrorHandler from "@/hooks/useApiResponseErrorHandler";
import { useLoading } from "@/states/loading/operations";
import { useServiceFormAtom } from "@/states/service-form/selectors";
import { SubscriptionServiceInfo } from "@/states/service-form/types";
import { formatNumber } from "@/utils/number";

const stripePromise = loadStripe(
  process.env.NEXT_PUBLIC_STRIPE_CONNECT_KEY ?? "",
);

const stripeOptions: StripeElementsOptions = {
  appearance: { theme: "stripe" } as Appearance,
  locale: "ja",
};

const cardElementStyle = {
  style: {
    base: { fontSize: "16px", color: "#1A1A1A" },
  },
};

interface PaymentFormProps {
  orderUuid: string;
  serviceId: number;
  serviceMenuId: number;
  serviceName: string;
  menuPrice: number;
  formUuid: string;
}

const PaymentForm: React.FC<PaymentFormProps> = ({
  orderUuid,
  serviceId,
  serviceMenuId,
  serviceName,
  menuPrice,
  formUuid,
}) => {
  const stripe = useStripe();
  const elements = useElements();
  const router = useRouter();
  const withLoading = useLoading();
  const { handlePageError } = useApiResponseErrorHandler();

  const [cardErrors, setCardErrors] = useState({
    number: "",
    expiry: "",
    cvc: "",
  });
  const [submitError, setSubmitError] = useState<string | undefined>(undefined);

  const handleCardChange = useCallback(
    (
      field: "number" | "expiry" | "cvc",
      e:
        | StripeCardNumberElementChangeEvent
        | StripeCardExpiryElementChangeEvent
        | StripeCardCvcElementChangeEvent,
    ) => {
      setCardErrors((prev) => ({ ...prev, [field]: e.error?.message ?? "" }));
    },
    [],
  );

  const handleSubmit = useCallback(async () => {
    if (!stripe || !elements) return;

    const cardElement = elements.getElement(CardNumberElement);
    if (!cardElement) return;

    setSubmitError(undefined);

    await withLoading(async () => {
      try {
        const { error, paymentMethod } = await stripe.createPaymentMethod({
          type: "card",
          card: cardElement,
        });

        if (error || !paymentMethod) {
          setSubmitError(error?.message ?? "カード情報が正しくありません");
          return;
        }

        const result = await reservationApi.postReservationPayment({
          postReservationPaymentRequest: {
            service: {
              id: serviceId,
              service_detail_ids: [],
              service_menu_id: serviceMenuId,
              order_uuid: orderUuid,
            },
            customer: {},
            payment_method_id: paymentMethod.id,
          },
        });

        const clientSecret = result.data?.client_secret;

        if (clientSecret) {
          const { error: confirmError } =
            await stripe.confirmCardPayment(clientSecret);
          if (confirmError) {
            setSubmitError(
              confirmError.message ?? "お支払いに失敗しました",
            );
            return;
          }
        }
        // Trial or already-confirmed: no client_secret
        void router.push(`/subscription/${formUuid}/complete?orderUuid=${orderUuid}`);
      } catch (e) {
        if (e instanceof ApiResponseError) {
          await handlePageError(e);
        } else {
          setSubmitError("エラーが発生しました。もう一度お試しください。");
        }
      }
    });
  }, [
    stripe,
    elements,
    serviceId,
    serviceMenuId,
    orderUuid,
    formUuid,
    router,
    withLoading,
    handlePageError,
  ]);

  return (
    <div className="max-w-xl mx-auto px-4 py-8">
      <h1 className="text-xl font-bold text-primary mb-2">{serviceName}</h1>
      <p className="text-2xl font-bold mb-6">
        ¥{formatNumber(menuPrice)}<span className="text-sm font-normal text-gray-500">/</span>
      </p>

      <div className="bg-white rounded-lg border border-gray-200 p-6 mb-6 space-y-5">
        <h2 className="text-base font-bold">クレジットカード情報</h2>

        <div>
          <label className="block text-sm font-medium text-gray-700 mb-1">
            カード番号
          </label>
          <div className="border border-gray-300 rounded px-3 py-2">
            <CardNumberElement
              options={cardElementStyle}
              onChange={(e) => handleCardChange("number", e)}
            />
          </div>
          {cardErrors.number && (
            <p className="text-red-500 text-xs mt-1">{cardErrors.number}</p>
          )}
        </div>

        <div className="grid grid-cols-2 gap-3">
          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">
              有効期限
            </label>
            <div className="border border-gray-300 rounded px-3 py-2">
              <CardExpiryElement
                options={cardElementStyle}
                onChange={(e) => handleCardChange("expiry", e)}
              />
            </div>
            {cardErrors.expiry && (
              <p className="text-red-500 text-xs mt-1">{cardErrors.expiry}</p>
            )}
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">
              セキュリティコード
            </label>
            <div className="border border-gray-300 rounded px-3 py-2">
              <CardCvcElement
                options={cardElementStyle}
                onChange={(e) => handleCardChange("cvc", e)}
              />
            </div>
            {cardErrors.cvc && (
              <p className="text-red-500 text-xs mt-1">{cardErrors.cvc}</p>
            )}
          </div>
        </div>
      </div>

      {submitError && (
        <p className="text-red-500 text-sm mb-4">{submitError}</p>
      )}

      <Button
        type="button"
        onClick={() => void handleSubmit()}
        className="w-full"
        disabled={!stripe}
      >
        申し込む
      </Button>

      <p className="text-xs text-gray-500 text-center mt-4">
        お支払いはStripeにより安全に処理されます
      </p>
    </div>
  );
};

const SubscriptionPaymentPage: NextPageWithLayout = () => {
  const router = useRouter();
  const { uuid, orderUuid, serviceMenuId } = router.query;
  const formUuid = typeof uuid === "string" ? uuid : "";
  const orderUuidStr = typeof orderUuid === "string" ? orderUuid : "";
  const serviceMenuIdNum = typeof serviceMenuId === "string" ? Number(serviceMenuId) : 0;

  const serviceFormAtom = useServiceFormAtom();
  const serviceInfo = serviceFormAtom.formData.service as
    | SubscriptionServiceInfo
    | undefined;

  const selectedMenu = useMemo(() => {
    if (serviceInfo?.type !== "subscription") return undefined;
    return serviceInfo.details.find((d) => d.id === serviceMenuIdNum);
  }, [serviceInfo, serviceMenuIdNum]);

  if (!router.isReady || !orderUuidStr) {
    return <div className="flex justify-center py-16">読み込み中...</div>;
  }

  return (
    <div className="min-h-screen bg-gray-50">
      <Elements stripe={stripePromise} options={stripeOptions}>
        <PaymentForm
          orderUuid={orderUuidStr}
          serviceId={serviceInfo?.id ?? 0}
          serviceMenuId={serviceMenuIdNum}
          serviceName={serviceInfo?.name ?? ""}
          menuPrice={selectedMenu?.price ?? 0}
          formUuid={formUuid}
        />
      </Elements>
      <AdvertisementFooter plan={serviceInfo?.plan ?? undefined} />
    </div>
  );
};

SubscriptionPaymentPage.getLayout = (page: ReactElement) => page;

export default SubscriptionPaymentPage;
  • Step 4: Verify TypeScript
cd yoyacoo_fe/customer && yarn typecheck 2>&1 | grep "subscription/\[uuid\]/payment" | head -10
  • Step 5: Run linter
cd yoyacoo_fe/customer && yarn analyse 2>&1 | tail -20
  • Step 6: Commit
cd yoyacoo_fe
git add customer/src/pages/subscription/[uuid]/payment.tsx
git commit -m "feat(subscription): implement customer subscription payment page"

Task 6: FE Customer — Subscription Complete Page

The complete page shows a success message after payment. It receives orderUuid as a query param.

File: yoyacoo_fe/customer/src/pages/subscription/[uuid]/complete.tsx

  • Step 1: Write the complete page

Replace yoyacoo_fe/customer/src/pages/subscription/[uuid]/complete.tsx with:

import { NextPageWithLayout } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { ReactElement } from "react";

import { UserPlan } from "@/apis/clients";
import { AdvertisementFooter } from "@/components/organisms/footer/AdvertisementFooter";
import { useServiceFormAtom } from "@/states/service-form/selectors";
import { SubscriptionServiceInfo } from "@/states/service-form/types";

import CheckCircleIcon from "/public/icon/check-circle.svg";

const SubscriptionCompletePage: NextPageWithLayout = () => {
  const router = useRouter();
  const { orderUuid } = router.query;
  const orderUuidStr = typeof orderUuid === "string" ? orderUuid : "";

  const serviceFormAtom = useServiceFormAtom();
  const serviceInfo = serviceFormAtom.formData.service as
    | SubscriptionServiceInfo
    | undefined;

  return (
    <div className="min-h-screen bg-gray-50">
      <div className="max-w-xl mx-auto px-4 py-16 text-center">
        <div className="flex justify-center mb-6">
          <CheckCircleIcon className="w-16 h-16 text-green-500" />
        </div>

        <h1 className="text-2xl font-bold text-primary mb-3">
          お申し込みが完了しました
        </h1>

        <p className="text-gray-600 mb-2">
          {serviceInfo?.name && (
            <span className="font-medium">{serviceInfo.name}</span>
          )}
          へのサブスクリプションが開始されました。
        </p>

        {orderUuidStr && (
          <p className="text-sm text-gray-500 mb-8">
            受付番号: {orderUuidStr}
          </p>
        )}

        <p className="text-sm text-gray-500 mb-8">
          ご登録いただいたメールアドレスに確認メールをお送りしました。
        </p>

        <div className="space-y-3">
          <Link
            href="/mypage"
            className="block w-full py-3 px-6 bg-primary text-white rounded-lg font-bold hover:opacity-90 transition-opacity"
          >
            マイページへ
          </Link>
        </div>
      </div>

      <AdvertisementFooter plan={serviceInfo?.plan ?? UserPlan.FREE} />
    </div>
  );
};

SubscriptionCompletePage.getLayout = (page: ReactElement) => page;

export default SubscriptionCompletePage;
  • Step 2: Verify TypeScript
cd yoyacoo_fe/customer && yarn typecheck 2>&1 | grep "subscription/\[uuid\]/complete" | head -10
  • Step 3: Run linter
cd yoyacoo_fe/customer && yarn analyse 2>&1 | tail -20
  • Step 4: Commit
cd yoyacoo_fe
git add customer/src/pages/subscription/[uuid]/complete.tsx
git commit -m "feat(subscription): implement customer subscription complete page"

Notes & Known Limitations

Regarding plan prop on AdvertisementFooter

The swagger spec for SubscriptionElement (customer API) already includes a plan field at line 72 of customer/components/services/_subscription.yaml. However SubscriptionServiceInfo in types.ts does not currently include it. The operations function that maps API response to SubscriptionServiceInfo also omits it.

Fix before Task 4: In yoyacoo_fe/customer/src/states/service-form/types.ts, add plan?: UserPlan to SubscriptionServiceInfo. In yoyacoo_fe/customer/src/states/service-form/operations.ts, add plan: serviceInfoDto.plan to the subscription mapping block (around line 800). This ensures AdvertisementFooter receives the correct plan.

Regarding CheckCircleIcon in complete page

/public/icon/check-circle.svg does not exist in the customer app. Use circle.svg or checkbox.svg from /public/icon/ instead, or use an inline Tailwind-styled checkmark div. Replace the SVG import line with a simple div:

<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mb-6 mx-auto">
  <span className="text-green-500 text-3xl font-bold"></span>
</div>

Regarding reservationApi.postPayment request shape

Verify the exact request body shape for subscription payments by checking:

grep -A 20 "postPayment\|PostReservationPaymentRequest" yoyacoo_fe/customer/src/apis/clients/api.ts | head -40

The service object may need service_menu_id and order_uuid — adjust the request if the generated types differ.

Regarding trial subscriptions

When the selected menu has a free trial, the Stripe subscription is created with trial_end set. The payment intent may be null (no immediate charge). The payment page’s handleSubmit handles this with the if (clientSecret) guard — if no clientSecret, it redirects to complete directly.

Out of scope (Phase 1)

  • Customer mypage subscription management (/mypage/subscriptions) — directory exists but empty
  • Admin view of subscribers
  • Subscription upgrade/downgrade