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 (
- [x]) 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_cycleon services, subscription fields onservice_menu(trial_period_, contract_period_),stripe_product_idon services,stripe_price_idon service_menu,stripe_customer_idon users - ✅
StripeSubscriptionSynctrait — syncs Stripe products and recurring prices when user creates/edits a subscription service - ✅ Supplier (user app):
CreateSubscriptionInteractor,UpdateSubscriptionInteractor,SubscriptionMenuInteractor - ✅ Customer:
OrderServiceTypeInteractor/SubscriptionInteractor— creates anOrderrecord (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— handlesinvoice.payment_succeeded,invoice.payment_failed
Frontend (feat/redmine_956):
- ✅ Supplier: full
SubscriptionServiceForm.tsx(create/edit) - ✅ Customer:
SubscriptionServiceInfo/SubscriptionServiceDetailtypes inservice-form/types.ts - ✅ Customer:
useFetchServiceFormhandles 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:
- Critical bug:
PaymentServiceTypeInteractor/SubscriptionInteractor.phpuses platform’sstripe.payment.secretand creates a fresh Stripe Customer each time — must use Connect API withtransfer_data[destination]to route funds to the user’s connected account - 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.yamlRegenerate:
yoyacoo_fe/customer/src/apis/clients/(auto-generated — do not edit directly)Step 1: Add
service_menu_idandorder_uuidtoCustomerReservationServiceDto
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
editorDisplayOptionsto 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
formDataServiceto theuseMemodependency 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:
- Page loads → reads
formUuidfrom URL[uuid] - Calls
useFetchServiceForm(formUuid)to populateserviceFormAtomwith subscription service data - Shows subscription menus (name, price, trial info, contract period)
- Customer selects a menu + enters name / email / phone
- 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 4serviceMenuId— 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