🎯 サーバー実装概要

天気保証サービス統合の基本フロー

既存の予約システムに天気保証機能を追加し、雨天時の全額返金を自動化

💡 実装のポイント

1

決済統合

基本料金 + 保証料で
一括決済処理

2

API連携

決済成功後に
LinkTrip APIへ保証データ送信

3

ウィジェット表示

予約完了画面で
アプリ連携ウィジェット表示

🔧 技術要件

HTTP通信

POST /api/service/weathercover/create-weathercover

  • JSON形式のリクエスト/レスポンス
  • API Key認証
  • HTTPS必須

データ形式

保証データの構造化送信

  • 事業者ID・予約ID
  • 金額・日付情報
  • 場所・ルール情報

エラー処理

堅牢なエラーハンドリング

  • リトライ機能
  • タイムアウト設定
  • ログ記録

🔄 決済〜API連携の詳細フロー

⚙️ 既存決済システムの改修

改修箇所: 決済処理ロジックに保証料を上乗せ

🔧 必要な修正作業

  • 料金計算部分: 基本料金に保証料(基本料金×10%)を加算
  • 決済API呼び出し: 合計金額で既存の決済処理を実行
  • データベース保存: 決済IDと保証料を既存テーブルに追加保存
  • 画面表示: 内訳表示に「天気保証料」項目を追加
例: ゴルフ料金 15,000円 + 保証料 1,500円 = 決済額 16,500円

🌐 LinkTrip API連携の追加

追加実装: 決済成功後に新規でLinkTrip APIを呼び出し

🔧 新規追加コード

  • API呼び出し処理: POST /api/service/weathercover/create-weathercover
  • 認証ヘッダー: Authorization: Bearer [事業者APIキー]
  • 送信データ構築: 予約ID・金額・日付・場所情報をJSON形式で送信
  • エラーハンドリング: API失敗時のリトライ・ログ出力処理
📝 実装ポイント: 既存の予約確定処理の直後に追加実装

📄 予約完了画面の修正

改修箇所: 完了画面にアプリ連携ウィジェットを表示

🔧 画面修正作業

  • HTMLテンプレート: 予約完了画面にウィジェット用div要素を追加
  • JavaScript追加: LinkTripウィジェットSDKの読み込み・初期化
  • データ受け渡し: API取得したdocumentIdを画面に渡す
  • 条件分岐: 保証付き予約の場合のみウィジェット表示
🎯 実装結果: お客様がボタンをタップしてアプリで返金受け取り可能

⏰ 重要なタイミング

重要

決済確定後の即座な処理: 決済が成功した直後に、失敗する前にLinkTrip APIを呼び出す

非同期処理での実装: ユーザーの待機時間を短縮し、UX向上を図る

📡 LinkTrip API詳細仕様

基本情報

エンドポイント: POST /api/service/weathercover/create-weathercover
認証方式: API Key認証(Authorization ヘッダー)
Content-Type: application/json
タイムアウト: 30秒(推奨)

📥 リクエストパラメータ

必須パラメータ

パラメータ 説明
partnerId string 事業者ID(LinkTripより発行) "gdo"
totalAmount number 基本料金(円・税込) 15000
fee number 実際に支払った保証料(円・税込) 1500
guaranteeDate string 保証対象日(YYYY-MM-DD形式) "2025-12-29"
bookingId string 事業者システムの予約番号 "BK20251229001"

任意パラメータ

パラメータ 説明 デフォルト値
partnerLocationId string 不要(Single方式では使用しません) null

📍 場所解決方式の選択

事業者の拠点数・運用方針に応じて、適切な場所解決方式を選択できます:

推奨対象
  • 単一拠点事業者(1店舗のゴルフ場等)
  • 少数拠点事業者(3-5店舗以下)
  • 全拠点で統一的な天気対応をする事業者
特徴
  • ✅ 設定が簡単(事前設定のみ)
  • ✅ API呼び出し時の追加パラメータ不要
  • ✅ 開発工数が少ない
  • ⚠️ 全拠点で同じ場所の天気を参照
設定方法

事業者登録時にdefaultLocationIdを設定するだけで利用可能

推奨対象
  • 全国チェーン事業者
  • 地域密着型多店舗事業者
  • 拠点別に正確な天気情報が必要な事業者
特徴
  • ✅ 拠点別に最適な天気情報を取得
  • ✅ 地域差を考慮した保証判定
  • ✅ 柔軟な拠点管理が可能
  • ⚠️ 拠点マッピング設定が必要
  • ⚠️ API呼び出し時にpartnerLocationId必須
設定方法

事業者の各拠点IDとLinkTripの場所IDのマッピングテーブルを事前に設定

📤 レスポンス仕様

成功レスポンス(201 Created)

{
  "success": true,
  "data": {
    "documentId": "gdo-BK20251229001",
    "locationId": "loc_1234567890",
    "locationResolution": "mapping",
    "guaranteeDate": "2025-12-29",
    "totalAmount": 15000,
    "fee": 1500
  },
  "message": "weathercover が正常に作成されました"
}

エラーレスポンス

{
  "success": false,
  "errorType": "VALIDATION_ERROR",
  "errorMessage": "Validation failed: totalAmount must be a non-negative number"
}

💻 言語別実装例

Node.js + Express実装例

const express = require('express');
const fetch = require('node-fetch');

// 天気保証付き予約完了処理
app.post('/complete-booking-with-guarantee', async (req, res) => {
  const { bookingId, totalAmount, guaranteeFee, playDate, golfCourseId } = req.body;

  try {
    // 1. 決済処理(既存のロジック)
    const paymentResult = await processPayment({
      amount: totalAmount + guaranteeFee,
      description: `ゴルフ予約 + 天気保証 (${bookingId})`
    });

    if (!paymentResult.success) {
      return res.status(400).json({
        error: 'Payment failed',
        details: paymentResult.error
      });
    }

    // 2. LinkTrip API呼び出し
    const weathercoverData = {
      partnerId: process.env.LINKTRIP_PARTNER_ID,
      bookingId: bookingId,
      totalAmount: totalAmount,
      fee: guaranteeFee,
      guaranteeDate: playDate,
      partnerLocationId: golfCourseId
    };

    console.log('Creating weathercover:', weathercoverData);

    const apiResponse = await fetch(
      `${process.env.LINKTRIP_API_BASE}/api/service/weathercover/create-weathercover`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${process.env.LINKTRIP_API_KEY}`
        },
        body: JSON.stringify(weathercoverData),
        timeout: 30000
      }
    );

    if (!apiResponse.ok) {
      const errorData = await apiResponse.json();
      throw new Error(`API error: ${errorData.errorMessage || apiResponse.status}`);
    }

    const result = await apiResponse.json();
    console.log('Weathercover created successfully:', result.data);

    // 3. 成功時の処理
    res.redirect(`/booking-complete?id=${bookingId}&guarantee=${result.data.documentId}`);

  } catch (error) {
    console.error('Weather guarantee creation failed:', error);

    // エラーハンドリング:保証作成失敗でも予約は完了扱い
    res.redirect(`/booking-complete?id=${bookingId}&guarantee_error=true`);
  }
});

PHP(WordPress)実装例

<?php
/**
 * 天気保証付き予約完了処理
 */
function complete_booking_with_guarantee($booking_data) {
    try {
        // 1. 決済処理
        $payment_result = process_payment([
            'amount' => $booking_data['total_amount'] + $booking_data['guarantee_fee'],
            'description' => sprintf('ゴルフ予約 + 天気保証 (%s)', $booking_data['booking_id'])
        ]);

        if (!$payment_result['success']) {
            throw new Exception('Payment failed: ' . $payment_result['error']);
        }

        // 2. LinkTrip API呼び出し
        $weathercover_data = [
            'partnerId' => get_option('linktrip_partner_id'),
            'bookingId' => $booking_data['booking_id'],
            'totalAmount' => intval($booking_data['total_amount']),
            'fee' => intval($booking_data['guarantee_fee']),
            'guaranteeDate' => $booking_data['play_date'],
            'partnerLocationId' => $booking_data['golf_course_id']
        ];

        error_log('Creating weathercover: ' . json_encode($weathercover_data));

        $response = wp_remote_post(
            get_option('linktrip_api_base') . '/api/service/weathercover/create-weathercover',
            [
                'headers' => [
                    'Content-Type' => 'application/json',
                    'Authorization' => 'Bearer ' . get_option('linktrip_api_key')
                ],
                'body' => json_encode($weathercover_data),
                'timeout' => 30
            ]
        );

        if (is_wp_error($response)) {
            throw new Exception('API request failed: ' . $response->get_error_message());
        }

        $status_code = wp_remote_retrieve_response_code($response);
        if ($status_code !== 201) {
            $error_body = json_decode(wp_remote_retrieve_body($response), true);
            throw new Exception('API error: ' . ($error_body['errorMessage'] ?? $status_code));
        }

        $result = json_decode(wp_remote_retrieve_body($response), true);
        error_log('Weathercover created successfully: ' . json_encode($result['data']));

        // 3. 成功時の処理
        return [
            'success' => true,
            'document_id' => $result['data']['documentId'],
            'booking_id' => $booking_data['booking_id']
        ];

    } catch (Exception $e) {
        error_log('Weather guarantee creation failed: ' . $e->getMessage());

        // エラーハンドリング:保証作成失敗でも予約は完了扱い
        return [
            'success' => false,
            'error' => $e->getMessage(),
            'booking_id' => $booking_data['booking_id']
        ];
    }
}
?>

Python/Django実装例

import requests
import logging
from django.conf import settings
from django.http import JsonResponse
from django.shortcuts import redirect

logger = logging.getLogger(__name__)

def complete_booking_with_guarantee(request):
    """天気保証付き予約完了処理"""
    try:
        booking_data = request.POST

        # 1. 決済処理(既存のロジック)
        payment_result = process_payment({
            'amount': int(booking_data['total_amount']) + int(booking_data['guarantee_fee']),
            'description': f"ゴルフ予約 + 天気保証 ({booking_data['booking_id']})"
        })

        if not payment_result['success']:
            raise ValueError(f"Payment failed: {payment_result['error']}")

        # 2. LinkTrip API呼び出し
        weathercover_data = {
            'partnerId': settings.LINKTRIP_PARTNER_ID,
            'bookingId': booking_data['booking_id'],
            'totalAmount': int(booking_data['total_amount']),
            'fee': int(booking_data['guarantee_fee']),
            'guaranteeDate': booking_data['play_date'],
            'partnerLocationId': booking_data.get('golf_course_id')
        }

        logger.info(f'Creating weathercover: {weathercover_data}')

        response = requests.post(
            f"{settings.LINKTRIP_API_BASE}/api/service/weathercover/create-weathercover",
            headers={
                'Content-Type': 'application/json',
                'Authorization': f'Bearer {settings.LINKTRIP_API_KEY}'
            },
            json=weathercover_data,
            timeout=30
        )

        response.raise_for_status()
        result = response.json()

        if not result['success']:
            raise ValueError(f"API returned error: {result.get('errorMessage', 'Unknown error')}")

        logger.info(f'Weathercover created successfully: {result["data"]}')

        # 3. 成功時の処理
        return redirect(f'/booking-complete?id={booking_data["booking_id"]}&guarantee={result["data"]["documentId"]}')

    except Exception as e:
        logger.error(f'Weather guarantee creation failed: {e}')

        # エラーハンドリング:保証作成失敗でも予約は完了扱い
        return redirect(f'/booking-complete?id={booking_data.get("booking_id", "unknown")}&guarantee_error=true')

Ruby on Rails実装例

require 'net/http'
require 'json'

class BookingController < ApplicationController
  # 天気保証付き予約完了処理
  def complete_with_guarantee
    begin
      # 1. 決済処理(既存のロジック)
      payment_result = process_payment(
        amount: params[:total_amount].to_i + params[:guarantee_fee].to_i,
        description: "ゴルフ予約 + 天気保証 (#{params[:booking_id]})"
      )

      unless payment_result[:success]
        raise StandardError, "Payment failed: #{payment_result[:error]}"
      end

      # 2. LinkTrip API呼び出し
      weathercover_data = {
        partnerId: Rails.application.credentials.linktrip[:partner_id],
        bookingId: params[:booking_id],
        totalAmount: params[:total_amount].to_i,
        fee: params[:guarantee_fee].to_i,
        guaranteeDate: params[:play_date],
        partnerLocationId: params[:golf_course_id]
      }

      Rails.logger.info "Creating weathercover: #{weathercover_data}"

      uri = URI("#{Rails.application.credentials.linktrip[:api_base]}/api/service/weathercover/create-weathercover")
      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = true
      http.read_timeout = 30

      request = Net::HTTP::Post.new(uri)
      request['Content-Type'] = 'application/json'
      request['Authorization'] = "Bearer #{Rails.application.credentials.linktrip[:api_key]}"
      request.body = weathercover_data.to_json

      response = http.request(request)

      unless response.code == '201'
        error_data = JSON.parse(response.body) rescue {}
        raise StandardError, "API error: #{error_data['errorMessage'] || response.code}"
      end

      result = JSON.parse(response.body)
      Rails.logger.info "Weathercover created successfully: #{result['data']}"

      # 3. 成功時の処理
      redirect_to booking_complete_path(
        id: params[:booking_id],
        guarantee: result['data']['documentId']
      )

    rescue => e
      Rails.logger.error "Weather guarantee creation failed: #{e.message}"

      # エラーハンドリング:保証作成失敗でも予約は完了扱い
      redirect_to booking_complete_path(
        id: params[:booking_id] || 'unknown',
        guarantee_error: true
      )
    end
  end

  private

  def process_payment(amount:, description:)
    # 既存の決済処理ロジック
    # 実装は事業者の決済システムに依存
    { success: true }
  end
end

⚠️ エラー処理とベストプラクティス

🚨 エラータイプと対処方法

エラータイプ HTTPステータス 原因 対処方法 リトライ
VALIDATION_ERROR 400 必須パラメータ不足・形式エラー リクエストデータを修正して再送信 ❌ 不要
PARTNER_NOT_FOUND 404 事業者IDが存在しない 正しいpartnerIdを設定 ❌ 不要
RULE_NOT_FOUND 400 保証ルールが存在しない partnerIdに紐づく保証ルール設定を確認 ❌ 不要
DUPLICATE_DOCUMENT 409 同じ予約IDで既に作成済み 重複チェック処理を追加 ❌ 不要
LOCATION_RESOLUTION_FAILED 400 場所IDの解決に失敗 partnerLocationIdを確認 ❌ 不要
SERVER_ERROR 500 サーバー内部エラー 時間をおいて再実行 ✅ 推奨
Network Timeout - ネットワークの問題 タイムアウト設定を調整 ✅ 推奨

🎯 実装のベストプラクティス

🔄

リトライ処理

ネットワークエラーや5xxエラー時は指数バックオフでリトライ

// リトライロジックの例
const maxRetries = 3;
const baseDelay = 1000; // 1秒

for (let attempt = 1; attempt <= maxRetries; attempt++) {
  try {
    const response = await callLinktripAPI(data);
    return response; // 成功時は即座に返す
  } catch (error) {
    if (attempt === maxRetries || !shouldRetry(error)) {
      throw error; // 最終試行または非リトライエラー
    }

    const delay = baseDelay * Math.pow(2, attempt - 1);
    await new Promise(resolve => setTimeout(resolve, delay));
  }
}
⏱️

タイムアウト設定

30秒のタイムアウトを設定し、ユーザー体験を向上

推奨設定:
  • 接続タイムアウト: 10秒
  • 読み込みタイムアウト: 30秒
  • 合計タイムアウト: 35秒
📝

ログ記録

API呼び出しの成功・失敗を必ずログに記録

// ログ記録の例
console.log('[LINKTRIP_API]', {
  action: 'create_weathercover',
  partnerId: data.partnerId,
  bookingId: data.bookingId,
  status: 'success',
  documentId: result.data.documentId,
  timestamp: new Date().toISOString()
});
🛡️

重複防止

同一予約IDでの重複呼び出しを防ぐチェック機能

// 重複チェックの例
const existingGuarantee = await checkExistingGuarantee(bookingId);
if (existingGuarantee) {
  console.log('Guarantee already exists:', existingGuarantee);
  return existingGuarantee; // 既存データを返す
}
🔄

グレースフル フォールバック

API失敗時でも予約処理は継続し、顧客体験を保持

フォールバック戦略:
  • 予約完了は正常に表示
  • 天気保証は「処理中」として表示
  • 後でバッチ処理で再試行
  • 顧客に適切な説明を提供

💻 包括的なエラーハンドリング実装例

async function createWeathercoverWithRetry(weathercoverData, maxRetries = 3) {
  const baseDelay = 1000;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(API_ENDPOINT, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${API_KEY}`
        },
        body: JSON.stringify(weathercoverData),
        timeout: 30000
      });

      // HTTPエラーをチェック
      if (!response.ok) {
        const errorData = await response.json();

        // リトライすべきでないエラー
        if ([400, 404, 409].includes(response.status)) {
          throw new NonRetryableError(errorData.errorMessage, errorData.errorType);
        }

        // リトライ可能なエラー
        throw new RetryableError(errorData.errorMessage, response.status);
      }

      const result = await response.json();

      // 成功ログ
      logger.info('Weathercover created successfully', {
        documentId: result.data.documentId,
        attempt: attempt
      });

      return result;

    } catch (error) {
      logger.error('Weathercover creation failed', {
        error: error.message,
        attempt: attempt,
        data: weathercoverData
      });

      // 最終試行または非リトライエラー
      if (attempt === maxRetries || error instanceof NonRetryableError) {
        throw error;
      }

      // 指数バックオフ
      const delay = baseDelay * Math.pow(2, attempt - 1);
      logger.info(`Retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

class NonRetryableError extends Error {
  constructor(message, type) {
    super(message);
    this.name = 'NonRetryableError';
    this.type = type;
  }
}

class RetryableError extends Error {
  constructor(message, status) {
    super(message);
    this.name = 'RetryableError';
    this.status = status;
  }
}

🧪 テスト方法

🔍 テスト項目チェックリスト

基本機能テスト

バリデーションテスト

エラーハンドリングテスト

🎯 テストデータ例

成功パターン

{
  "partnerId": "test-partner",
  "bookingId": "TEST_BOOKING_20251229_001",
  "totalAmount": 15000,
  "fee": 1500,
  "guaranteeDate": "2025-12-29",
  "partnerLocationId": "test_golf_course_001",
  "originalFee": 2000,
  "usedPoint": 500
}

エラーパターン(バリデーション)

{
  "partnerId": "test-partner",
  // bookingId が不足
  "totalAmount": -100, // 負の値
  "fee": 1500,
  "guaranteeDate": "2023-12-29", // 過去の日付
  "partnerLocationId": "test_golf_course_001"
}

🔧 テスト環境の構築

1

開発環境でのAPI接続

テスト用のpartnerId・API Keyを使用

// 環境変数設定例
LINKTRIP_API_BASE=https://staging-api.linktrip.co.jp
LINKTRIP_PARTNER_ID=test-partner
LINKTRIP_API_KEY=test_key_1234567890
2

モックサーバーの利用

開発初期段階でのテスト用モック

// Jest + nock を使ったモックテスト例
const nock = require('nock');

nock('https://api.linktrip.co.jp')
  .post('/api/service/weathercover/create-weathercover')
  .reply(201, {
    success: true,
    data: { documentId: 'mock-document-id' }
  });
3

統合テスト

実際のAPI環境での動作確認

本番前の最終確認として、ステージング環境で実施