Integrating with Kodepay

一、Kodepay后台使用

此模块的功能是帮助你的插件创建对应的付费套餐,例如你的插件准备使用9.99美元/月的订阅套餐,和1.99美元的一次新支付。

设置好付费套餐后,即可在插件侧接入代码,进行你自己插件的相关开发工作

1.1 注册Kodepay账号

进入网站https://kodepay.io/,免费注册账号

1.2 创建套餐

注意:创建账号成功后,默认进入的【测试模式】,【测试模式】和【正式模式】后台的操作完全一致,在【测试模式】下你可以使用测试的信用卡号进行支付、开发相关调试,没问题后切换到【正式模式】进行正式的插件发布上架即可。

第一步:添加插件

截图 说明
添加插件入口
名称(必填):推荐和你你上线的插件名称一致 icon:插件图标说明:开发者的对插件的说明描述商店地址:插件上架的商店地址
创建成功后,会自动生成ID,该ID会用于后续代码调用中

第二步:创建支付套餐

截图 说明
支付套餐添加入口
名称(必填):套餐的名称描述:套餐简介计费方式:月级循环订阅、一次性付费价格目前价格不支持自定义,可以根据需要选择0.99  1.99等不同的阶梯,支持人民币价格

到达这一步,插件和套餐相关配置就已经配置完成,可以进行代码的开发和调试

二、插件侧代码接入

注意:price价格页面需要开发者自己写,其他的的支付页面、用户身份认证页面都由Kodepay封装好,直接调用即可。

2.1  接入JS代码

2.1.1 接入流程

插件需要添加必要的权限

"permissions": [
    "storage" // 存储api_key值信息及用户信息
]

storage 中目前存储了两个信息

client_id + '_user' :  最新的用户信息

client_id + '_ext_key' : 用于请求kodepay接口及标识用户(不可修改!)

2.1.1.1 background 接入

background 目前是最稳妥的接入方式,直接在content或者popup中接入存在通讯的问题,不需要设置回调可以在content 或者popup 中使用(后续会考虑兼容,目前暂时不支持)

目前相关JS代码已经封装成为NPM包,地址:https://www.npmjs.com/package/kodepay?activeTab=versions

版本:"kodepay": "^1.1.3"

background(service_workder)中引入方式:

// service_worker
import {Kodepay} from "kodepay";
kodepay_client = Kodepay.kodepay(application_id, client_id, mode)
// example: kodepay_client = Kodepay.kodepay(application_id, client_id, 'development')
// get user info 
kodepay_client.get_user_info().then((user) => {
      console.log(user);
 });
// open login in page
kodepay_client.open_login_page();

// open user management page
kodepay_client.open_user_management_page();

// open payment page
kodepay_client.open_payment_page(price_id);


// set callback
kodepay_client.on_pay_completed.addListener((user, status) => {
  console.log(user, status);
});

kodepay_client.on_login_completed.addListener((user) => {
  console.log(user);
});

参数说明:(参数顺序是固定的)

参数 说明 示例 是否必填
application_id 应用ID,标识唯一应用 d14286cc-0436-11ee-8cc6-aecfa10d9dac Y
client_id 客户端ID,也就是插件的ID(由kodepay网站标识插件的ID,非浏览器标识插件的ID) e5739a78-0436-11ee-8cc6-aecfa10d9dac Y
mode 开发模式可选值:'development' = > 测试模式'production' => 生产模式 'development' N (默认是’development‘)

注意:请确保development模式测试通过后再切换到production模式,在kodepay.io网站上同样也存在两种模式,正式模式和测试模式分别与插件的production、development是相对应的,development模式只能访问到kodepay.io的测试模式的配置和数据,production模式只能访问到kodepay.io的正式模式的配置和数据,其他情况是不允许的。

2.1.1.2 content.js 接入(可选)

需要注入到kodepay.io网站前端页面上,用于接收用户的行为信息(目前只支持三种行为信息: 支付成功、支付失败、登录成功),如果不需要尽可能及时获取到这三个行为和自动触发你设置的回调函数,可以不用接入(这些信息会在用户信息API中返回,当你调用用户信息接口,它返回的信息始终是最新的)

所需权限:

"content_scripts": [
    {
      // other content-scripts
    },
    {
      "js": [
        "js/kodepayContent.js"
      ],
      "matches": [
        "https://kodepay.io/*"
      ],
      "run_at": "document_start"
    }
  ]

js/kodepayContent.js (业务侧需要确保注入成功,import的方式不一定会被插件支持,可以考虑用打包工具将这个JS打包)

import {KodepayContent} from 'kodepay';
KodepayContent.kodepay_content_start_listener();

2.2 提供的API

API 参数 作用 返回类型
get_user_info 获取用户信息 Promise
get_valid_subscriptions 获取用户有效套餐 Promise
open_login_page width: 打开窗口宽度(默认1100px)heigh: 打开窗口高度(默认700px) 打开一个登录页面(弹窗)
open_user_management_page width: 打开窗口宽度(默认1100px)heigh: 打开窗口高度(默认700px) 打开用户支付管理页面
open_payment_page() plan_idwidth: 打开窗口宽度(默认1100px)heigh: 打开窗口高度(默认700px) 打开支付页面

get_user_info API 的返回体

{
    "code": 100000,
    "message": "success",
    "userinfo": {
        "application_id": "519a73da-0f6e-4bd4-ae2a-add09b26ba01",
        "extension_id": "008b09de-28f1-377d-b6aa-0cce36c50fc4",
        "user_id": "c765a5c4-0041-11ee-bb5d-0242ac120003",
        "email": "[email protected]",
        "created_at": "2023-06-01 06:01:41.680654+00:00",
        "last_login_time": null
    },
    "payinfo": [
        {
            "transaction_id": "5ff57734-0041-11ee-9e17-0242ac120003",
            "plan_type": "recurring",//套餐类型:'one_time':"一次性支付",'recurring':"订阅"
            "order_status": "created", //订阅状态:null:无订阅,created:首次订阅,updated:续订,canceling:取消中,canceled:取消成功,pastdue:已过期,invalid:已失效
            "pay_status": "succeed", //支付状态,created:支付创建;succeed:支付成功;failed:支付失败;refunded:退款
            "plan_start": 123,  //套餐权限开始时间,时间戳
            "plan_end": 456,  //套餐权限结束时间,时间戳
            "currency": "usd", //币种
            "plan_price": 1,  //价格ID,单位分
            "plan_id": 1,  //套餐id
            "plan_name": "产品测试", //套餐名称
            "channel": "stripe", //支付方式,值可能为:stripe、alipay、wechat、paypal
            "pay_time": 1234567890,//支付时时间戳,
            "prod_code": "prod_c2317eb3a0864fc9", // 就是plan_id
            "created_at": "2023-06-01 05:58:48",
            "updated_at": "2023-06-01 13:58:48"
        }
    ]
}

get_valid_subscriptions API返回体

// 只返回当前生效的套餐,建议各个业务侧通过prod_code(就是套餐的ID)判断用户具体的套餐和权限
[
        {
            "transaction_id": "5ff57734-0041-11ee-9e17-0242ac120003",
            "plan_type": "recurring",//套餐类型:'one_time':"一次性支付",'recurring':"订阅"
            "order_status": "created", //订阅状态:null:无订阅,created:首次订阅,updated:续订,canceling:取消中,canceled:取消成功,pastdue:已过期,invalid:已失效
            "pay_status": "succeed", //支付状态,created:支付创建;succeed:支付成功;failed:支付失败;refunded:退款
            "plan_start": 123,  //套餐权限开始时间,时间戳
            "plan_end": 456,  //套餐权限结束时间,时间戳
            "currency": "usd", //币种
            "plan_price": 1,  //价格ID,单位分
            "plan_id": 1,  //套餐id
            "plan_name": "产品测试", //套餐名称
            "channel": "stripe", //支付方式,值可能为:stripe、alipay、wechat、paypal
            "pay_time": 1234567890,//支付时时间戳,
            "prod_code": "prod_c2317eb3a0864fc9", // 就是plan_id
            "created_at": "2023-06-01 05:58:48",
            "updated_at": "2023-06-01 13:58:48"
        }
    ]

2.3 设置回调(即将发布)

依赖于2.1.1.2 content.js 接入,否则无法保证可靠性

关于支付回调:

kodepay页面收到支付消息、登录消息后会通过content.js 向background.js 发送消息来触发回调行为,同时background也会开始轮询判断支付是否成功、登录是否成功

// set callback 希望业务侧也防止触发多次的情况
kodepay_client.on_pay_completed.addListener((user, status) => {
  console.log(user, status);
});
kodepay_client.on_login_completed.addListener((user) => {
  console.log(user);
});

支付回调:可以设置多个回调,会按照顺序触发,user参数是更新后的用户信息, status的值可能有两个状态succeed(支付成功) 和 failed(支付失败)

登录成功回调:可以设置多个回调,会按照顺序触发,user参数是更新后的用户信息,业务侧也可以通过监听storage.local来判断用户是否登录成功

三、便捷接入示例代码

3.1  price 页面(只提供示例代码,给需要做price页面的提供参考)

最终的结构:

// 读取 JSON 文件
fetch('price.json')
    .then(response => response.json())
    .then(data => {
      const container = document.getElementById('price-cards-container');
      let prodCode = '';
      // 点击事件处理函数
      function handleCardClick(event) {
        // 取消先前选中的卡片样式
        const selectedCard = container.querySelector('.price-card.selected');
        if (selectedCard) {
          selectedCard.classList.remove('selected');
        }
        // 添加选中样式到当前点击的卡片
        const clickedCard = event.currentTarget;
        clickedCard.classList.add('selected');

        // 获取当前选中卡片的 prod_code
        prodCode = clickedCard.dataset.prodCode;
        document.getElementById('sub-pri').innerText = clickedCard.dataset.price;
      }

      function openSubscribePage () {
        console.log('prodCode:', prodCode);
        if (prodCode)  {
          chrome.runtime.sendMessage({target: 'BACKGROUND', type: 'OPEN PAYMENT PAGE', price_id: prodCode});
        }
      }
      // 初始化函数,将第一个卡片设置为选中状态
      function initialize() {
        const firstCard = container.querySelector('.price-card');
        if (firstCard) {
          firstCard.classList.add('selected');
          // 获取第一个卡片的 prod_code
          prodCode = firstCard.dataset.prodCode;
          document.getElementById('sub-pri').innerText = firstCard.dataset.price;
          console.log('Selected prod_code:', prodCode);
        }
      }
      function openLoginPage () {
        chrome.runtime.sendMessage({target: 'BACKGROUND', type: 'OPEN LOGIN PAGE'});
      }
      // 为每个卡片创建 HTML 内容并添加点击事件监听器
      data.forEach(card => {
        const cardElement = document.createElement('div');
        cardElement.classList.add('price-card');
        cardElement.dataset.prodCode = card.prod_code;
        cardElement.dataset.price = card.price;

        // 创建标题元素
        const titleElement = document.createElement('h2');
        titleElement.textContent = card.title;

        // 创建描述元素
        const descriptionElement = document.createElement('p');
        descriptionElement.textContent = card.description;

        // 创建功能说明列表
        const featuresList = document.createElement('ul');
        featuresList.classList.add('feature-list');

        card.features.forEach(feature => {
          if (feature.show) {
            const featureItem = document.createElement('li');
            featureItem.classList.add('feature-item');

            // 应用功能项的样式配置
            Object.assign(featureItem.style, feature.style);

            const iconElement = document.createElement('span');
            iconElement.classList.add('icon');
            iconElement.innerHTML = feature.is_active ? '<svg t="1686542124400" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2798" width="16" height="16"><path d="M402.326 848.721l-391.6-391.6c-14.3-14.3-14.3-37.4 0-51.7s37.4-14.3 51.7 0l339.8 339.9 559.3-559.3c14.3-14.3 37.4-14.3 51.7 0 14.3 14.3 14.3 37.4 0 51.7l-610.9 611z" fill="#27D0D8" p-id="2799"></path></svg>' : '<svg t="1686542202784" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6559" id="mx_n_1686542202785" width="16" height="16"><path d="M463.787 512l-253.44-253.44a34.133 34.133 0 0 1 48.213-48.213L512 463.787l253.44-253.44a34.133 34.133 0 1 1 48.213 48.213L560.213 512l253.44 253.44a34.133 34.133 0 1 1-48.213 48.213L512 560.213l-253.44 253.44a34.133 34.133 0 0 1-48.213-48.213z" fill="#d81e06" p-id="6560"></path></svg>';
            featureItem.appendChild(iconElement);
            featureItem.innerHTML += feature.title;
            featuresList.appendChild(featureItem);
          }
        });

        cardElement.appendChild(titleElement);
        cardElement.appendChild(descriptionElement);
        cardElement.appendChild(featuresList);
        container.appendChild(cardElement);
      });

      // 添加点击事件监听器
      const priceCards = container.querySelectorAll('.price-card');
      priceCards.forEach(card => {
        card.addEventListener('click', handleCardClick);
      });
      const subscribe_btn = document.getElementById('subscribe-btn');
      subscribe_btn.addEventListener('click', openSubscribePage);


      const subLogin = document.getElementById('sub-login');
      subLogin.addEventListener('click', openLoginPage);
      // 初始化,默认选中第一个卡片
      initialize();
      });
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>price</title>
  <style>
    html, body {
        width: 100%;
        height: 100%;
        margin: 0;
        padding: 0;
        color: #101010;
    }
    .price-card {
        border: 1px solid #ccc;
        min-width: 200px;
        min-height: 300px;
        border-radius: 5px;
        padding: 20px;
        margin-bottom: 20px;
        cursor: pointer;
    }
    .subscription-button {
        padding: 6px 12px;
        border-radius: 4px;
        background-color: rgba(90, 150, 255, 1);
        color: rgba(255, 255, 255, 1);
        cursor: pointer;
        display: flex;
        align-items: center;
        justify-content: center;
    }
    .price-card.selected {
        border-color: blue;
    }
    .icon {
        font-size: 20px;
        margin-right: 10px;
        color: green;
    }
    h2 {
        margin-top: 0;
    }
    ul {
        list-style-type: none;
        margin-top: 10px;
        margin-bottom: 0;
        padding-left: 0;
    }
    li {
        margin-bottom: 5px;
        text-align: left;
    }
    .container {
        display: flex;
        align-items: center;
        justify-content: center;
        flex-direction: column;
        width: 100%;
        height: 100%
    }
    #price-cards-container {
        display: flex;
        flex-wrap: wrap;
        justify-content: center;
    }
    .t-co {
        line-height: 20px;
        border-radius: 5px;
        background-color: rgba(255, 255, 255, 1);
        color: rgba(16, 16, 16, 1);
        text-align: center;
        box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.3);
        min-height: 500px;
        min-width:800px;
    }
  </style>
</head>
<body>
 <div class="container">
   <div>
     <div class="t-co">
       <div>
         <div style="font-size: 20px;font-weight: 600;padding: 26px">
           Upgrade Plan to view more key data
         </div>
       </div>
       <div id="price-cards-container">
       </div>
       <div style="display: flex; align-items: center;justify-content: center;">
         <div class="subscription-button" id="subscribe-btn">
           <span id="sub-pri">

           </span>
           <span>  Subscribe Now</span>
         </div>
       </div>
       <div style="text-align: right;padding-right: 20px;">
           <span>
             Already purchased?
           </span>
           <span style="cursor: pointer;color: #2F54EB" id="sub-login">
             Sign in now
           </span>
         </div>
     </div>
   </div>
 </div>
<script src="price.js"></script>
</body>
</html>

price.json 业务侧可以尝试使用CDN获取,避免修改套餐需要重新上架插件

[
  {
    "title": "The Extension",
    "description": "",
    "price": "$2.99/month",
    "prod_code": "prod_b7028ddd3e1646ff",
    "features": [
      {
        "title": "APP Download Estimate",
        "is_active": true,
        "show": true,
        "style": {
          "color": "#565656",
          "fontSize": "14px"
        }
      },
      {
        "title": "APP Revenue Estimate",
        "is_active": false,
        "show": true,
        "style": {
          "color": "#565656",
          "fontSize": "14px"
        }
      },
      {
        "title": "APP Ranking History",
        "is_active": true,
        "show": true,
        "style": {
          "color": "#101010",
          "fontSize": "14px"
        }
      },
      {
        "title": "APP Version History",
        "is_active": true,
        "show": true,
        "style": {
          "color": "#101010",
          "fontSize": "14px"
        }
      }
    ]
  }
]
3.2 获取用户有效套餐(目前API 中有提供这个方法,但是该API是内部先请求了user_info 再调用这个方法解析, 如果只需要解析user_info ,可以复制这段代码)
// 这里的pay_info 是get_user_info 返回体中的payinfo
function get_valid_subscriptions (pay_info) {
  if (Array.isArray(pay_info) && pay_info.length > 0) {
    let success_status = ['created', 'updated'];
    let cancling_status = ['canceling', 'canceled'];
    return pay_info.filter((item) => {
      if (item.plan_type === 'one_time' && item.pay_status === 'succeed') {
        return item;
      }
      if (item.plan_type === 'recurring') {
        if (success_status.includes(item.order_status)) {
          return item;
        }
        if (cancling_status.includes(item.order_status)) {
          // 判断plan_end 这个时间戳与当前时间戳的大小
          if (item.plan_end > Date.now() / 1000) {
            return item;
          }
        }
      }
    });
  }
}