DevBoi

[Flutter] Boot pay로 정기 결제 구현하기 본문

[Mobile]/[Flutter]

[Flutter] Boot pay로 정기 결제 구현하기

HiSmith 2023. 6. 10. 01:51
반응형

우선, 플러터로 정기결제 구현하기다.

최대한 간편하고 쉽게 구현할 예정이다.

 

1) 부트 페이 관리자 세팅

 

결제수단 설정으로, 샌드박스 모드를 켜둔다.

카드 결제 + 카드정기 결제수단을 허용하기 때문에 해당만 켜두었다.

해당 세팅이 끝나면, web,aos,ios에 대한 아이디 값을 받게 된다.(연동 키값)

 

 

 

2. pubspec.yaml 파일에 추가

dependencies:
  flutter:
    sdk: flutter


  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.

  assets_audio_player: ^3.0.6
  google_sign_in: ^6.1.0
  flutter_time_picker_spinner: ^2.0.0
  provider: ^6.0.5
  shared_preferences: ^2.1.1
  dart_json_mapper: ^2.2.7
  bootpay: ^4.6.2

 

자, 이제 신규 의존성을 넣어줬으니, 아래 명령어를 쳐준다.

flutter pub get

*

shared_preferences: ^2.0.17

해당 버전에 대한 수정도 필요했다.

기존에는 2.1.1 이었는데, 해당버전이 높아서, 낮춰줘야한다고 빌드시에 나오길래 우선 변경했다.

만약에 shared_preferences 버전을 2.1.1로 유지해야한다면, 부트페이 버전을 낮추는것을 고려하자

 

 

3. Aos,IOS설정

Aos는 설정할 것이 없다.

근데, clean http를 위해서는 androidmanifest파일에 대한 수정이 필요하다고 해서 일단 추가했다.

usersCleartestTraffice을 true로 해주는 옵션이다.

 <application
        android:label="inna"
        android:name="${applicationName}"
        android:usesCleartextTraffic="true">

 

IOS는 공식 문서에 따라 아래 값을 지정해준다.

** {your project root}/ios/Runner/Info.plist ** CFBundleURLSchemes의 값은 개발사에서 고유값으로 지정해주셔야 합니다. 외부앱(카드사앱)에서 다시 기존 앱으로 앱투앱 호출시 필요한 스키마 값입니다.

해당 info.plist파일의 내용을 아래와 같이 바꿔준다.

PG사에서 결제를 하고, 해당 앱스킴을 통해서 결제 상태 이후, 다시 앱을 호출하는 url로 생각하면된다.

<key>CFBundleURLTypes</key>
	<array>
		<dict>
			<key>CFBundleTypeRole</key>
			<string>Editor</string>
			<key>CFBundleURLSchemes</key>
			<array>
				<string>요기에는 특별하게 들어갈 앱 이름</string>
			</array>
		</dict>
	</array>

 

성공적으로 빌드되는지를 체크하고, 샘플 소스를 가이드 문서 따라서 작성했다.

우선 결제창으로 넘어가는 곳은 TextButton widget으로 생성했고,

부트페이에 대한 설정을 했으면, 인증키 관리라는 메뉴에서 발급하는 해당 키값을 하단에 넣어주면 된다.

부분적으로 부트페이에 필요한 소스(가이드 참고) 만 짤라서 보여주면 아래와 같다.

TextButton(
	onPressed: () => bootpayTest(context),
	child: const Text('정기결제 테스트(인증)', style: TextStyle(fontSize: 16.0))
)
 void bootpayTest(BuildContext context) {
    Payload payload = getPayload();
    if(kIsWeb) {
      payload.extra?.openType = "iframe";
    }

    Bootpay().requestSubscription(
      context: context,
      payload: payload,
      showCloseButton: false,
      // closeButton: Icon(Icons.close, size: 35.0, color: Colors.black54),
      onCancel: (String data) {
        print('------- onCancel: $data');
      },
      onError: (String data) {
        print('------- onCancel: $data');
      },
      onClose: () {
        print('------- onClose');
        Bootpay().dismiss(context); //명시적으로 부트페이 뷰 종료 호출
        //TODO - 원하시는 라우터로 페이지 이동
      },
      onIssued: (String data) {
        print('------- onIssued: $data');
      },
      onConfirm: (String data) {
        /**
            1. 바로 승인하고자 할 때
            return true;
         **/
        /***
            2. 비동기 승인 하고자 할 때
            checkQtyFromServer(data);
            return false;
         ***/
        /***
            3. 서버승인을 하고자 하실 때 (클라이언트 승인 X)
            return false; 후에 서버에서 결제승인 수행
         */
        // checkQtyFromServer(data);
        return true;
      },
      onDone: (String data) {
        print('------- onDone: $data');
      },
    );
  }
Payload getPayload() {
    Payload payload = Payload();
    Item item1 = Item();
    item1.name = "미키 '마우스"; // 주문정보에 담길 상품명
    item1.qty = 1; // 해당 상품의 주문 수량
    item1.id = "ITEM_CODE_MOUSE"; // 해당 상품의 고유 키
    item1.price = 500; // 상품의 가격

    Item item2 = Item();
    item2.name = "키보드"; // 주문정보에 담길 상품명
    item2.qty = 1; // 해당 상품의 주문 수량
    item2.id = "ITEM_CODE_KEYBOARD"; // 해당 상품의 고유 키
    item2.price = 500; // 상품의 가격
    List<Item> itemList = [item1, item2];


    payload.androidApplicationId = '647f251b755e27001c38d9d3'; // android application id
    payload.iosApplicationId = '647f251b755e27001c38d9d4'; // ios application id


    payload.pg = '나이스페이';
    payload.method = '카드자동';
    // payload.methods = ['card', 'phone', 'vbank', 'bank', 'kakao'];
    payload.orderName = "테스트 상품"; //결제할 상품명
    payload.price = 1000.0; //정기결제시 0 혹은 주석


    // payload.orderId = DateTime.now().millisecondsSinceEpoch.toString(); //주문번호, 개발사에서 고유값으로 지정해야함
    payload.subscriptionId = DateTime.now().millisecondsSinceEpoch.toString(); //주문번호, 개발사에서 고유값으로 지정해야함


    payload.metadata = {
      "callbackParam1" : "value12",
      "callbackParam2" : "value34",
      "callbackParam3" : "value56",
      "callbackParam4" : "value78",
    }; // 전달할 파라미터, 결제 후 되돌려 주는 값
    payload.items = itemList; // 상품정보 배열

    User user = User(); // 구매자 정보
    user.username = "사용자 이름";
    user.email = "user1234@gmail.com";
    user.area = "서울";
    user.phone = "010-4033-4678";
    user.addr = '서울시 동작구 상도로 222';

    Extra extra = Extra(); // 결제 옵션
    extra.appScheme = 'bootpayFlutterExample';
    extra.cardQuota = '3';
    // extra.openType = 'popup';

    // extra.carrier = "SKT,KT,LGT"; //본인인증 시 고정할 통신사명
    // extra.ageLimit = 20; // 본인인증시 제한할 최소 나이 ex) 20 -> 20살 이상만 인증이 가능

    payload.user = user;
    payload.extra = extra;
    return payload;
  }
import 'package:flutter/material.dart';
import 'package:bootpay/bootpay.dart';
import 'package:bootpay/model/extra.dart';
import 'package:bootpay/model/item.dart';
import 'package:bootpay/model/payload.dart';
import 'package:bootpay/model/user.dart';
import 'package:flutter/foundation.dart';

 

참고) 부트페이의 해당 메뉴에서 인증키를 확인할수 있다.

전체 코드는 아래와 같다.(스압주의)

import 'package:flutter/material.dart';
import 'package:bootpay/bootpay.dart';
import 'package:bootpay/model/extra.dart';
import 'package:bootpay/model/item.dart';
import 'package:bootpay/model/payload.dart';
import 'package:bootpay/model/user.dart';
import 'package:flutter/foundation.dart';

void premiumguide() {
  runApp(const MaterialApp(home: PremiumGuide()));
}

class PremiumGuide extends StatefulWidget {
  const PremiumGuide({Key? key}) : super(key: key);

  @override
  State<PremiumGuide> createState() => _PremiumGuideState();
}

class _PremiumGuideState extends State<PremiumGuide> {
  final PageController _pageController = PageController(viewportFraction: 0.65);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        elevation: 0,
        backgroundColor: Colors.transparent,
        actions: [
          InkWell(
            onTap: () {
              Navigator.pop(context);
            },
            splashColor: Colors.transparent,
            highlightColor: Colors.transparent,
            child: const Padding(
              padding: EdgeInsets.all(12.0),
              child: Icon(
                Icons.close,
                color: Colors.black,
              ),
            ),
          ),
        ],
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.start,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          Padding(
            padding: const EdgeInsets.fromLTRB(15, 5, 15, 45),
            child: SizedBox(
              height: 100,
              child: Row(
                mainAxisAlignment: MainAxisAlignment.start,
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  ClipRRect(
                    borderRadius: BorderRadius.circular(200),
                    child: Image.asset(
                      'assets/nalogo99.JPG',
                      fit: BoxFit.cover,
                    ),
                  ),
                  const Padding(
                    padding: EdgeInsets.only(left: 10),
                    child: Text(
                      '흥미로운 알람으로\n아침을 맞이해보세요',
                      style: TextStyle(
                        fontSize: 25,
                        fontFamily: 'SpoqaHanSansNeo',
                        fontWeight: FontWeight.w700,
                      ),
                    ),
                  )
                ],
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.fromLTRB(19, 0, 15, 20),
            child: Row(
              crossAxisAlignment: CrossAxisAlignment.center,
              mainAxisAlignment: MainAxisAlignment.start,
              children: const [
                Icon(
                  Icons.auto_awesome,
                  color: Color(0xff3AFFEE),
                ),
                Padding(
                  padding: EdgeInsets.fromLTRB(5, 1.5, 0, 0),
                  child: Text(
                    '인나를 소개합니다!',
                    style: TextStyle(
                      fontFamily: 'SpoqaHanSansNeo',
                      fontWeight: FontWeight.w500,
                      fontSize: 18,
                    ),
                  ),
                ),
              ],
            ),
          ),
          SizedBox(
            height: 290,
            child: NotificationListener<OverscrollIndicatorNotification>(
              onNotification: (OverscrollIndicatorNotification overscroll) {
                overscroll.disallowIndicator();
                return true;
              },
              child: PageView(
                physics: const ClampingScrollPhysics(),
                controller: _pageController,
                children: [
                  Container(
                    margin: const EdgeInsets.fromLTRB(10, 0, 10, 0),
                    decoration: BoxDecoration(
                      color: const Color(0xff3AFFEE),
                      borderRadius: BorderRadius.circular(12),
                    ),
                  ),
                  Container(
                    margin: const EdgeInsets.fromLTRB(10, 0, 10, 0),
                    decoration: BoxDecoration(
                      color: const Color(0xff3AFFEE),
                      borderRadius: BorderRadius.circular(12),
                    ),
                  ),
                  Container(
                    margin: const EdgeInsets.fromLTRB(10, 0, 10, 0),
                    decoration: BoxDecoration(
                      color: const Color(0xff3AFFEE),
                      borderRadius: BorderRadius.circular(12),
                    ),
                  ),
                ],
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.fromLTRB(15, 10, 15, 0),
            child: SizedBox(
              height: 65,
              child: InkWell(
                onTap: () {},
                splashColor: Colors.transparent,
                highlightColor: Colors.transparent,
                borderRadius: BorderRadius.circular(12),
                child: Container(
                  decoration: BoxDecoration(
                    borderRadius: BorderRadius.circular(12),
                    color: const Color(0xff3AFFEE),
                  ),
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: const [
                      Center(
                        child: Text(
                          '7일 무료체험 후 월 정기결제',
                          style: TextStyle(
                            fontSize: 14,
                            fontWeight: FontWeight.w400,
                            fontFamily: 'SpoqaHanSansNeo',
                            color: Colors.black,
                          ),
                        ),
                      ),
                      Center(
                        child: Text(
                          '9,900원',
                          style: TextStyle(
                            fontSize: 26,
                            fontWeight: FontWeight.w500,
                            fontFamily: 'SpoqaHanSansNeo',
                            color: Colors.black,
                          ),
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.only(top: 20),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children:  [
                const Text(
                  'ㆍ결제일로부터 7일간 무료체험 기간입니다.',
                  style: TextStyle(
                      color: Colors.grey,
                      fontSize: 14,
                      fontFamily: 'SpoqaHanSansNeo',
                      fontWeight: FontWeight.w300),
                ),
                const Text(
                  'ㆍ무료체험 기간 중 자동 결제를 취소할 수 있습니다.',
                  style: TextStyle(
                      color: Colors.grey,
                      fontSize: 14,
                      fontFamily: 'SpoqaHanSansNeo',
                      fontWeight: FontWeight.w300),
                ),
                const Text(
                  'ㆍ7일 이후부터는 월 정기결제 요금이 자동 결제되며,이후에 앱 사용을 중단하려면 월정액 자동 결제를 취소하여야 합니다.',
                  style: TextStyle(
                      color: Colors.grey,
                      fontSize: 14,
                      fontFamily: 'SpoqaHanSansNeo',
                      fontWeight: FontWeight.w300),
                ),
                TextButton(
                    onPressed: () => bootpayTest(context),
                    child: const Text('정기결제 테스트(인증)', style: TextStyle(fontSize: 16.0))
                )
              ],
            ),
          ),
        ],
      ),
    );
  }
  void bootpayTest(BuildContext context) {
    Payload payload = getPayload();
    if(kIsWeb) {
      payload.extra?.openType = "iframe";
    }

    Bootpay().requestSubscription(
      context: context,
      payload: payload,
      showCloseButton: false,
      // closeButton: Icon(Icons.close, size: 35.0, color: Colors.black54),
      onCancel: (String data) {
        print('------- onCancel: $data');
      },
      onError: (String data) {
        print('------- onCancel: $data');
      },
      onClose: () {
        print('------- onClose');
        Bootpay().dismiss(context); //명시적으로 부트페이 뷰 종료 호출
        //TODO - 원하시는 라우터로 페이지 이동
      },
      onIssued: (String data) {
        print('------- onIssued: $data');
      },
      onConfirm: (String data) {
        /**
            1. 바로 승인하고자 할 때
            return true;
         **/
        /***
            2. 비동기 승인 하고자 할 때
            checkQtyFromServer(data);
            return false;
         ***/
        /***
            3. 서버승인을 하고자 하실 때 (클라이언트 승인 X)
            return false; 후에 서버에서 결제승인 수행
         */
        // checkQtyFromServer(data);
        return true;
      },
      onDone: (String data) {
        print('------- onDone: $data');
      },
    );
  }
  Payload getPayload() {
    Payload payload = Payload();
    Item item1 = Item();
    item1.name = "미키 '마우스"; // 주문정보에 담길 상품명
    item1.qty = 1; // 해당 상품의 주문 수량
    item1.id = "ITEM_CODE_MOUSE"; // 해당 상품의 고유 키
    item1.price = 500; // 상품의 가격

    Item item2 = Item();
    item2.name = "키보드"; // 주문정보에 담길 상품명
    item2.qty = 1; // 해당 상품의 주문 수량
    item2.id = "ITEM_CODE_KEYBOARD"; // 해당 상품의 고유 키
    item2.price = 500; // 상품의 가격
    List<Item> itemList = [item1, item2];


    payload.androidApplicationId = ''; // android application id
    payload.iosApplicationId = ''; // ios application id


    payload.pg = '나이스페이';
    payload.method = '카드자동';
    // payload.methods = ['card', 'phone', 'vbank', 'bank', 'kakao'];
    payload.orderName = "테스트 상품"; //결제할 상품명
    payload.price = 1000.0; //정기결제시 0 혹은 주석


    // payload.orderId = DateTime.now().millisecondsSinceEpoch.toString(); //주문번호, 개발사에서 고유값으로 지정해야함
    payload.subscriptionId = DateTime.now().millisecondsSinceEpoch.toString(); //주문번호, 개발사에서 고유값으로 지정해야함


    payload.metadata = {
      "callbackParam1" : "value12",
      "callbackParam2" : "value34",
      "callbackParam3" : "value56",
      "callbackParam4" : "value78",
    }; // 전달할 파라미터, 결제 후 되돌려 주는 값
    payload.items = itemList; // 상품정보 배열

    User user = User(); // 구매자 정보
    user.username = "사용자 이름";
    user.email = "user1234@gmail.com";
    user.area = "서울";
    user.phone = "010-4033-4678";
    user.addr = '서울시 동작구 상도로 222';

    Extra extra = Extra(); // 결제 옵션
    extra.appScheme = 'bootpayFlutterExample';
    extra.cardQuota = '3';
    // extra.openType = 'popup';

    // extra.carrier = "SKT,KT,LGT"; //본인인증 시 고정할 통신사명
    // extra.ageLimit = 20; // 본인인증시 제한할 최소 나이 ex) 20 -> 20살 이상만 인증이 가능

    payload.user = user;
    payload.extra = extra;
    return payload;
  }
}

 

해당 으로 빌드하다가 오류가 발생했다.

<오류 내용>

File not found: /Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/arc/libarclite_iphoneos.a

 

 

<해결 방법>

PodFile의 내용의 post_install부분을 아래와같이 바꿔주면된다.

post_install do |installer|
  installer.generated_projects.each do |project|
    project.targets.each do |target|
        target.build_configurations.each do |config|
            config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
        end
    end
end
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)
  end
end

 

 

무튼 이렇게 호출을 하게 되고 정상적으로 카드에 대한 결제 성공처리까지 완료가 되면

onDone에서 전달해주는 requestId가 return되게 된다.

{"receipt_id":"12kjsdfsf",
"subscription_id":"dsfdas1213",
"gateway_url":"https://gw.bootpay.co.kr",
"metadata":{"callbackParam1":"value12","callbackParam2":"value34","callbackParam3":"value56","callbackParam4":"value78"},
"pg":"나이스페이먼츠","method":"카드자동(REST)",
"method_symbol":"card_rebill_rest",
"method_origin":"카드자동(REST)","
method_origin_symbol":"card_rebill_rest",
"published_at":"2023-06-10T13:01:07+09:00",
"requested_at":"2023-06-10T13:00:10+09:00",
"status_locale":"빌링키발급완료","status":11,"receipt_data":

자, 이제 receipt_id 기준으로 서버사이드에서 빌링키를 조회 및 결제 처리를 해야한다.

정기결제는 부트페이에서 진행해주지 않기때문에, 서버사이드에서 스케줄러를 통해서, 빌링키를 발급받아서 결제처리를 해야한다.

 

 

<서버사이드>

bootPay객체는 연동키 관리에서 rest application key, private key 두개로 생성가능하다.

무튼 해당 으로하면 accToken이 주어지게 된다.

@RestController
public class BootPayScheduler {

  @RequestMapping("/bootPay/test")
  public void getBillingKey() throws Exception {

    Bootpay bootpay = new Bootpay("restapikey", "privatekey");
    HashMap res = bootpay.getAccessToken();
    if (res.get("error_code") == null) { //success
      System.out.println("goGetToken success: " + res);
    } else {
      System.out.println("goGetToken false: " + res);
    }
  }
}

 

일단 추가로 acctoken을 바탕으로, 해당 receipt_id를 가지고 빌링키를 조회해보자

코드는 아래와 같다. 우선 외부모듈에 대한 테스트이기 때문에 코드는 개그지같다 ㅋ

 

package com.inna.innabackend.scheduler;

import kr.co.bootpay.Bootpay;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;

@RestController
public class BootPayScheduler {

  @RequestMapping("/bootPay/test")
  public void getBillingKey() throws Exception {

    Bootpay bootpay = new Bootpay("-", "-=");
    HashMap res = bootpay.getAccessToken();
    String accToken =(String) res.get("access_token");
    System.out.println("accToken : %s" + accToken);
    HashMap billingRes = bootpay.lookupBillingKey("receipt-id");
    String billingKey = (String) billingRes.get("billing_key");
    System.out.printf( "JSON: %s", billingKey);

  }
}

 

일단 내가 생각하는 구조는

백엔드에 receipt-id를 던져주면 해당 기준으로 스케줄러가 돌아서, 빌링키가 없는것들을 조회해서 빌링키를 디비에 넣어주고

해당 스케줄러로, 결제일자와 스케줄러 동작시점이 동일하다면, 해당 결제 승인을 하는것이다.

스케줄러는...30분이나 10분에 한번씩 돌 예정이고,, 일단 해당 빌링키를 바탕으로 결제 요청을 해보자.

Bootpay bootpay = new Bootpay("-", "-");
    HashMap res = bootpay.getAccessToken();
    String accToken =(String) res.get("access_token");
    System.out.println("accToken : %s" + accToken);
    HashMap billingRes = bootpay.lookupBillingKey("-");
    String billingKey = (String) billingRes.get("billing_key");
    System.out.printf( "JSON: %s", billingKey);

    SubscribePayload payload = new SubscribePayload();
    payload.billingKey = billingKey;
    payload.orderName = "아이템01";
    payload.price = 100;
    payload.user = new User();
    payload.user.phone = "-";
    payload.orderId = "" + (System.currentTimeMillis() / 1000);

    HashMap payResponse = bootpay.requestSubscribe(payload);
    System.out.println("pay result : %s" + payResponse.toString());

 

일단 이렇게 하면, accToken으로 빌링키를 얻고, 해당 빌링 키로 결제까지 진행이 가능하다.

나는 두개의 스케줄러로

한개는 빌링키를 조회+저장

한개는 빌링키로, payTargetTime이 오늘이면,결제처리  를 구현해야하기때문에 

 

해당 메소드를 자르고, 공통화 하는 작업이 필요하다.

사람마다 쓰는 방법은 다를것같아서, 해당 방법은 알아서 잘 쓰면 될 것 같다.

반응형