Альтернативный сервис оплаты в приложениях и играх

66 minute read

План капкан: как принимать деньги в приложении без Google Play но с блекджеком и куртизанками. Приготовьтесь, текста будет много.

В этой статье мы пройдемся по созданию бекенда для сервиса покупок и напишем простое SDK для использования сервиса в приложении под Android. Обсудим почему важна авторизация пользователя, как проще ее сделать. Прикинем самый удобный API для платежного SDK. В результате у нас получится независимое решение, которое смело можно использовать в приложениях, публикуемых в некоторых альтернативных магазинах или если хотите публиковать как apk.

Если вам нужна помощь с установкой и настройкой такого сервиса для покупки товаров в играх и приложениях - пишите @akovardin. За донейшен помогу настроить ваш собственный сервис для приема платежей.

Как принимать оплату в современных реалиях

Для начала посмотрим какие есть альтернативы оплатам Google Play. Есть несколько вводных, на которые нужно ориентироваться:

  • Сервис могут использовать физики. Идеально, если он доступен для самозанятых
  • Желательно чтобы сервис мог поддерживать рекаринг платежи. Это необходимо для реализации подписок. Не всем приложениям нужны подписки, поэтому этот пункт не обязательный

yookassa

yookassa - Очень прокаченный инструмент для приема платежей. Есть и обычные покупки и рекаринг платежи. Его довольно сложно подключить, требуется заключение договоров, куча подтверждений. Но вся морока того стоит - сервис предоставляет все возможные функции, свое собственное SDK и API для бекенда.

intellectmoney

intellectmoney - Есть рекаринг платежи, достаточно удобное API. Поверх API можно написать свое SDK. Но пока есть проблемы с физиками самозанятыми. Как только вернут возможность, сразу заюзаю

boosty

boosty - Совсем не сервис платежей, но можно использовать для реализации подписок. Я уже писал про то, как это можно сделать. В будущем запилю отдельный сервис и отдельное SDK. Есть возможность работать с самозанятыми.

yoomoney

yoomoney - Супер простое API и очень простая реализация приема платежей. SDK написать достаточно просто. Сервис работает с самозанятыми. К сожалению, нет рекаринг платежей

Очевидно, я мог пропустить стоящие сервисы. Если вы знаете как еще можно принимать оплату в приложении для самозанятых, то обязательно напишите об этом в комментариях или свяжитесь со мной.

В этой статье я буду использовать yoomoney. Да, не будет подписок, но для моих целей они пока не нужны.

Схема работы

Кажется, что прием платежей в приложении - это достаточно замороченная штука. На самом деле все просто, если знать подход и иметь хорошую схему под рукой

Нам нужно начать с отображения списка товаров, затем реализовать процесс авторизации, а после сможем сделать процесс покупки и получения списка платежей.

Бекенд на Go

Начнем с серверной части. Бекенд будем реализовывать на Go с помощью моего любимого фреймворка PocketBase. Для большинства приложений его хватит с головой.

Список товаров

Нам нужно создать несколько коллекций. Первая будет описывать набор приложений, а вторая товары, доступные в этих приложениях. Третья коллекция описывает платежи, которые могут быть в разных статусах в зависимости от этапа оплаты

Набор полей для коллекции приложений - applications:

  • name - название приложения
  • bundle - бандл/идентификатор приложения
  • wallet - yoomoney кошелек
  • secret - секретный ключ, который используется для подтверждения оплаты через yoomoney
  • enabled - включение/отключение приложения

Набор полей для коллекции товаров - products:

  • name - название товара
  • price - стоимость товара
  • description - подробное описание, что за товар
  • enabled - включение/отключение товара
  • application - связь с приложением указывает на то, к какому приложению относится данный товар

Мы используем PocketBase, а значит нам не нужно делать дополнительные ручки. Можно воспользоваться ручками для работы с коллекциями, которые предоставляет сам PocketBase

Чтобы API стало доступно для всех пользователей, нужно указать настройки коллекции. Для этого в настройках коллекции нужно выбрать доступ нк только для администраторов для поиска и просмотра списка.

Получить список товаров через стандартное API можно так

1GET http://127.0.0.1:8081/api/collections/products/records?filter=(application='2ipc3erqkokl5kv' && enabled=true)
2Content-Type: application/json

Только учтите, что параметр filter нужно заэнкодить, чтобы указывать в URL. Такой фильтр будет использоваться в SDK ниже по тексту

Замечание. Возможно, использование готового API не всегда хорошая идея. В моем примере можно получить деактивированные товары поменяв условие фильтра. Вероятно, в следующих версиях это придется изменить

В ответ придет список товаров

 1{
 2  "page": 1,
 3  "perPage": 30,
 4  "totalItems": 2,
 5  "totalPages": 1,
 6  "items": [
 7    {
 8      "application": "2ipc3erqkokl5kv",
 9      "collectionId": "pkvh6fhkcoe8kk0",
10      "collectionName": "products",
11      "created": "2024-08-26 21:00:32.354Z",
12      "description": "example product",
13      "enabled": true,
14      "id": "4t0uvrhoyqqgpxk",
15      "name": "example",
16      "price": 10,
17      "updated": "2024-08-26 21:00:32.354Z"
18    },
19    {
20      "application": "2ipc3erqkokl5kv",
21      "collectionId": "pkvh6fhkcoe8kk0",
22      "collectionName": "products",
23      "created": "2024-08-26 21:13:47.641Z",
24      "description": "example2",
25      "enabled": true,
26      "id": "k9u6largvm5esjy",
27      "name": "example2",
28      "price": 3.2,
29      "updated": "2024-08-26 21:14:02.704Z"
30    }
31  ]
32}

Этого достаточно, чтобы отобразить список товаров в приложении

Пользователи

Пользователи очень важная тема в сервисе приема платежей. Все платежи будут привязываться к конкретным пользователям. Это нужно, чтобы была возможность восстановить список покупок после того как приложение будет переустановлено

Я буду использовать стандартные возможности PocketBase для работы с пользователями, но немного модернизирую регистрацию пользователей и авторизацию.

Пользователи будут создаваться автоматически, а авторизация будет проходить через указания пароля, полученного на почту. Такой подход улучшает пользовательский опыт и не требует запоминание паролей. Я сам его обожаю.

Для начала нужно настроить уже существующую коллекцию users, в которой будут храниться все наши пользователи.

Для кастомной авторизации мы будем искать пользователя в базе по указанной почте или создавать его, если такой почты еще нет

 1func (h *Users) Send(c echo.Context) error {
 2	data := struct {
 3		Email string `json:"email" form:"email"`
 4	}{}
 5	if err := c.Bind(&data); err != nil {
 6		return err
 7	}
 8
 9	record, err := h.app.Dao().FindFirstRecordByData("users", "email", data.Email)
10	if err != nil && !errors.Is(err, sql.ErrNoRows) {
11		return err
12	}
13
14	if record == nil {
15		users, err := h.app.Dao().FindCollectionByNameOrId("users")
16		if err != nil {
17			return err
18		}
19
20		record = models.NewRecord(users)
21		record.Set("email", data.Email)
22		record.Set("username", data.Email)
23
24	}
25
26	password := utils.RandomString(6, true, false, true)
27
28	// TODO: send to email
29
30	record.SetPassword(password)
31
32	if err := h.app.Dao().SaveRecord(record); err != nil {
33		return err
34	}
35
36	html, err := h.registry.LoadFS(views.FS,
37		"layout.html",
38		"users/code.html",
39	).Render(map[string]any{
40		"email":   data.Email,
41	})
42
43	if err != nil {
44		return err
45	}
46
47	return c.HTML(http.StatusOK, html)
48}

Генерируем пароль

Максимально простой метод для генерации пароля

 1package utils
 2
 3import (
 4	"golang.org/x/exp/rand"
 5)
 6
 7const (
 8	letterBytes  = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
 9	specialBytes = "!@#$%^&*()_+-=[]{}\\|;':\",.<>/?`~"
10	numBytes     = "0123456789"
11)
12
13func RandomString(length int, useLetters bool, useSpecial bool, useNum bool) string {
14	b := make([]byte, length)
15	for i := range b {
16		if useLetters {
17			b[i] = letterBytes[rand.Intn(len(letterBytes))]
18		} else if useSpecial {
19			b[i] = specialBytes[rand.Intn(len(specialBytes))]
20		} else if useNum {
21			b[i] = numBytes[rand.Intn(len(numBytes))]
22		}
23	}
24	return string(b)
25}

Так как пароль будет вводиться пользователем вручную, то удобно использовать 6ти значный цифро буквенный пароль

1password := utils.RandomString(6, true, false, true)

Такой пароль можно быстро ввести на клавиатуре телефона

Отправка пароля на почту

Окей, отправлять пароль на почту - отличная идея. Для этого нам нужен SMTP сервер, через который можно отправлять почту. Лучше использовать какой-нибудь готовый сервис, который позволит это делать не напрягаясь

Например, можно использовать Яндекс почту. Проще всего использовать личную почтовый ящик на Яндексе. Но если хотите кастомный ящик на своем домене, то вам придется стать обладателем Яндекс 360 и настроить свой домен.

Подробней о настройке почты можно почитать в документации Яндекс. А тут можно узнать про настройку почты на своем домене

После всех манипуляций в настройках домена и почты, у нас будут нужные данные:

  • Пароль password: xxxx
  • Адрес сервера для исходящей почты out: “smtp.yandex.ru”
  • Порт для отправки писем port: 465
  • От какого имени будем рассылать письма username. В моем случае это “robot@getapp.store

Все эти данные нужно указать в настройках сервиса на странице http://127.0.0.1:8080/_/#/settings/mail

Кстати, почитать про написание своего почтового сервера можно у меня на сайте в статье про SMTP

У нас есть все необходимые данные для отправки писем. Можно было бы использовать стандартный функционал PocketBase. Но, к сожалению, для отправки почты в PocketBase используется вызов консольной утилиты sendmail. Это лишние заморочки с настройкой контейнера. Я не понимаю зачем так реализовано, но наверняка на то были причины.

Поэтому, для отправки почты будем использовать кастомную логику на базе библиотеки gopkg.in/gomail.v2

 1type Config struct {
 2	Password string
 3	Out      string
 4	In       string
 5	Port     int
 6	Username string
 7}
 8
 9type Mailer struct {
10	config Config
11}
12
13func New(config Config) *Mailer {
14	return &Mailer{
15		config: config,
16	}
17}
18
19type Message struct {
20	From    string `json:"from_email"`
21	Name    string `json:"from_name"`
22	To      string `json:"to"`
23	Subject string `json:"subject"`
24	Html    string `json:"html"`
25}
26
27func (m *Mailer) Send(message Message) error {
28	msg := gomail.NewMessage()
29	msg.SetHeader("From", message.From)
30	msg.SetHeader("To", message.To)
31	msg.SetHeader("Subject", message.Subject)
32	msg.SetBody("text/html", message.Html)
33
34	n := gomail.NewDialer(m.config.Out, m.config.Port, m.config.Username, m.config.Password)
35
36	if err := n.DialAndSend(msg); err != nil {
37		return err
38	}
39
40	return nil
41}

Надеюсь, не нужно объяснять код выше. Все супер банально, под капотом gomail реализует SMTP протокол для взаимодействия с сервером и нам не нужны никакие консольные утилиты

Добавляем вызов метода Send(message Message) error в ручке Send(c echo.Context) error:

 1smtp := h.app.Settings().Smtp
 2mailer := mail.New(mail.Config{
 3    Password: smtp.Password,
 4    Out:      smtp.Host,
 5    Port:     smtp.Port,
 6    Username: smtp.Username,
 7})
 8
 9err = mailer.Send(mail.Message{
10    From:    h.app.Settings().Meta.SenderAddress,
11    Name:    h.app.Settings().Meta.SenderName,
12    To:      data.Email,
13    Subject: "Billing password",
14    Html:    "<p>Password: <b>" + password + "</b><p>",
15})

Указываем тему и тело письма. Письмо отправится как HTML документ, поэтому можно использовать разметку

Процесс авторизации

Чтобы начать процесс покупки товара, пользователю необходимо авторизоваться. Для этого будут использоваться два экрана. На первом нужно указать почту, на которую отправится пароль.

А на втором экране нужно ввести пароль, полученный на почту

В дальнейшем, для показа формы авторизации в приложении будет использоваться WebView внутри которого мы будем загружать и показывать нужные формы и странички

Обе странички и все необходимые ручки для авторизации/регистрации пользователей реализованы в контроллере users

  • func (h *Users) Login(c echo.Context) error - Ручка, которая рисует экран с формой ввода почтового ящика
  • func (h *Users) Send(c echo.Context) error - В этой ручке находим или создаем пользователях, генерируем и отправляем пароль на почту, рендерим форму для проверки пароля
  • func (h *Users) Check(c echo.Context) error - Проверяем пароль, логиним пользователя и редиректим на страничку success с указанием токена авторизации в GET параметре. Редирект выглядит так return c.Redirect(http.StatusSeeOther, "/v1/user/success?token="+token)
  • func (h *Users) Success(c echo.Context) error - Страничка с сообщением об удачной авторизации

Все эти ручки нужны для реализации пункта 2 и 3 из схемы выше.

Покупаем товар

Для процесса покупки товара нужно реализовать ручку в контроллере payments. Все ручки в контролере будут закрыты авторизацией. Для этого используется специальная миделваря

1// payments
2v1.GET("/:app/:product/payments/purchase", payments.Purchase, apis.RequireRecordAuth("users"))
3v1.GET("/:app/:product/payments/success", payments.Success, apis.RequireRecordAuth("users"))

В контроллере payments будет реализована все логика из пунктов 4, 5, 6 и 7

Ручка func (h *Payments) Purchase(c echo.Context) error готовит все необходимые данные для рендера формы оплаты товара. Вся логика оплаты крутиться вокруг “формы для перевода” - это очень простой способ принимать деньги с возможностью указывать кастомные параметры и получать уведомления о совершении оплаты

Как работает моя форма:

 1<form method="POST" action="https://yoomoney.ru/quickpay/confirm">
 2    <input type="hidden" name="receiver" value="{{.wallet}}"/>
 3    <input type="hidden" name="label" value="{{.label}}"/>
 4    <input type="hidden" name="quickpay-form" value="button"/>
 5    <input type="hidden" name="sum" value="{{.amount}}" data-type="number"/>
 6    <input type="hidden" name="paymentType" value="AC">
 7    <input type="hidden" name="successURL"
 8            value="{{.base}}/v1/{{.app}}/{{.product}}/payments/success?payment={{.payment}}"/>
 9    <div style="height: 50px;">
10    </div>
11    <div class="form-row">
12        <button type="submit" class="btn btn-primary">Купить</button>
13    </div>
14</form>

Разберемся с полями в этой форме и откуда какие значения в этой форме указываются:

receiver - В этом поле нужно указать кошелек, на которой будут переводиться деньги. Кошелек указываем в настройках приложения в коллекции applications: application.GetString("wallet")

label - Очень важное поле, тут нужно указать всю дополнительную информацию, которая понадобиться для подтверждения заказа. Формируется поле из идентификатора платежа, идентификатора продукта и приложения:

1label := record.Id + ":" + record.GetString("product") + ":" + product.GetString("application")

Это значение вернется в веб-хуке и позволит определить какой платеж был совершен.

quickpay-form - это фиксированное значение, всегда указывается button

sum - тут указывается сумма платежка. Эту сумму можно проверить в веб-хуке

paymentType - Способ оплаты. Возможные значения: PC — оплата из кошелька ЮMoney, AC — с банковской карты. Кмк, лучше всегда использовать AC

successURL - В поле указывается URL, на который заредиректит пользователя после оплаты на сайте yoomoney

{{.base}}/v1/{{.app}}/{{.product}}/payments/success?payment={{.payment}}

Обратите внимание, что ид платежа передается в GET параметре. Так проще получить это значение в приложении внутри SDK. Когда пользователя редиректит на страничку success платеж переводиться из статуса created в статус paid

 1id := c.QueryParam("payment")
 2
 3payment, err := h.app.Dao().FindFirstRecordByFilter(
 4    "payments",
 5    "id = {:id}",
 6    dbx.Params{"id": id},
 7)
 8
 9if err != nil {
10    return err
11}
12
13if payment.GetString("status") == StatusCreated {
14    payment.Set("status", StatusPaid)
15
16    if err := h.app.Dao().SaveRecord(payment); err != nil {
17        return err
18    }
19}

Обработка подтверждения от yoomoney

Остался последний шаг - получить подтверждение платежа от yoomoney. Первым делом нужно указать настройки веб-хука в кабинете yoomoney. У них это называется HTTP-уведомления:

Важно указать секрет для проверки подлинности. Этот серет нужно буден сохранить в настройках приложения в поле secret.

Уведомления будет отправляться на ручку unc (h *Payments) Confirm(c echo.Context) error. Данные приходят в JSON и парсятся в структуру:

 1data := struct {
 2    NotificationType string  `json:"notification_type" form:"notification_type"`
 3    BillId           string  `json:"bill_id" form:"bill_id"`
 4    Amount           float64 `json:"amount" form:"amount"`
 5    DateTime         string  `json:"datetime" form:"datetime"`
 6    Codepro          bool    `json:"codepro" form:"codepro"`
 7    Sender           string  `json:"sender" form:"sender"`
 8    Sha1Hash         string  `json:"sha1_hash" form:"sha1_hash"`
 9    OperationLabel   string  `json:"operation_label" form:"operation_label"`
10    OperationId      string  `json:"operation_id" form:"operation_id"`
11    Currency         int     `json:"currency" form:"currency"`
12    Label            string  `json:"label" form:"label"`
13}{}

Первым шагом нужно проверить подлинность данных. В запросе приходит поле sha1_hash - это подпись запроса, сгенерированная с учетом секрета, который мы указали в настройках. Нам нужно сгенерировать подпись на нашей стороне и сравнить ее с подписью из запроса

 1token := app.GetString("secret")
 2
 3check := data.NotificationType +
 4    "&" + data.OperationId +
 5    "&" + fmt.Sprintf("%.2f", data.Amount) +
 6    "&" + fmt.Sprintf("%d", data.Currency) +
 7    "&" + data.DateTime +
 8    "&" + data.Sender +
 9    "&" + fmt.Sprintf("%t", data.Codepro) +
10    "&" + token +
11    "&" + data.Label
12
13hash := sha1.New()
14hash.Write([]byte(check))
15sha := hex.EncodeToString(hash.Sum(nil))
16
17if sha != data.Sha1Hash {
18    h.app.Logger().Error("error on check hash", "counted", sha, "income", data.Sha1Hash)
19
20    return errors.New("error on check hash")
21}

При генерации подписи важно учитывать порядок параметров.

В полученных данных нас больше всего интересует поле label - в этом поле передается необходимая нам информация из платежной формы. Нужно распарсить эту информацию, чтобы получить идентификатор платежа. Логика работы с label используется и для генерации формы оплаты и для проверки информации из HTTP-уведомления. Логично вынести эту логику отдельно

 1type Label struct {
 2	Payment string
 3	Product string
 4	App     string
 5}
 6
 7func (l Label) Parse(in string) (Label, error) {
 8	parts := strings.Split(in, ":")
 9
10	if len(parts) < 3 {
11		return l, errors.New("invalid input")
12	}
13
14	l.Payment = parts[0]
15	l.Product = parts[1]
16	l.App = parts[2]
17
18	return l, nil
19}
20
21func (l Label) Format() string {
22	return l.Payment + ":" + l.Product + ":" + l.App
23}

Осталось проверить указанную сумму и перевести платеж в статус confirm

 1ecord, err := h.app.Dao().FindRecordById("payments", label.Payment)
 2if err != nil {
 3    h.app.Logger().Error("error on find payment", "err", err, "id", label.Payment)
 4
 5    return err
 6}
 7
 8record.Set("status", StatusConfirm)
 9
10if err := h.app.Dao().SaveRecord(record); err != nil {
11    return err
12}

На этом процесс оплаты завершен.

Список покупок/восстановление покупок

Список покупок доступен только для авторизированных, пользователь может видеть только свои покупки. Но для получения списка платежей нам не нужно делать отдельную ручку, достаточно воспользоваться стандартным API самого PocketBase. Пример запроса списка платежей:

1GET http://127.0.0.1:8081/api/collections/payments/records
2Content-Type: application/json
3Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MjcyNTc5NTYsImlkIjoiaHo0MjlpZjQ2MjFlaDdkIiwidHlwZSI6ImFkbWluIn0.AK2X3ny17U5hXVPyXGTnUpNiOEwWDQVOteZNzFBu7Vw

В ответ получим список платежей:

 1{
 2  "page": 1,
 3  "perPage": 30,
 4  "totalItems": 18,
 5  "totalPages": 1,
 6  "items": [
 7    {
 8      "amount": 3.2,
 9      "collectionId": "m8pbua5mie8cf29",
10      "collectionName": "payments",
11      "created": "2024-09-01 17:24:30.308Z",
12      "description": "",
13      "id": "u0ms2ok05m41a8w",
14      "name": "example2",
15      "product": "k9u6largvm5esjy",
16      "status": "confirm",
17      "updated": "2024-09-01 17:40:38.854Z",
18      "user": "ymxbvnqubr7klio"
19    },
20    {
21      "amount": 3.2,
22      "collectionId": "m8pbua5mie8cf29",
23      "collectionName": "payments",
24      "created": "2024-09-01 17:30:51.403Z",
25      "description": "",
26      "id": "2lnteevff3r5lnt",
27      "name": "example2",
28      "product": "k9u6largvm5esjy",
29      "status": "created",
30      "updated": "2024-09-01 17:30:51.403Z",
31      "user": "ymxbvnqubr7klio"
32    },
33    {
34      "amount": 3.2,
35      "collectionId": "m8pbua5mie8cf29",
36      "collectionName": "payments",
37      "created": "2024-09-01 17:32:50.690Z",
38      "description": "",
39      "id": "i0si9e5dll7d4hx",
40      "name": "example2",
41      "product": "k9u6largvm5esjy",
42      "status": "created",
43      "updated": "2024-09-01 17:32:50.690Z",
44      "user": "ymxbvnqubr7klio"
45    }
46  ]
47}

В этом JSON есть идентификатор продукта и статус платежа. В зависимости от этих данных можно выдавать пользователю купленные товары в приложении

Теперь разберемся как работает SDK внутри Android приложения

Android SDK

На самом деле, в SDK совсем мало логики. Его основная задача - показывать нужные странички в WebView и обрабатывать результаты редиректов. Во время авторизации мы получаем токен внутри SDK из GET параметра именно с помощью редиректа

Исходный код Android SDK можно почитать на gitflic

Показываем WebView

Для запросов получения списка платежей или показа формы оплаты нам необходимо пройти авторизацию и получить токен. Для этого в SDK есть отдельный метод

 1private fun auth(handler: AuthHandler) {
 2    // TODO: можно использовать хранилище. 
 3    // Сейчас токен храниться только в памяти
 4    if (savedToken != "") {
 5        handler.onSuccess(AuthResponse(token = savedToken))
 6        return
 7    }
 8
 9    val dialog = Dialog(context = context, url = "${api}/v1/user/login")
10    dialog.client = object : WebViewClient() {
11        override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
12            return super.shouldInterceptRequest(view, request)
13        }
14
15        override fun shouldOverrideUrlLoading(w: WebView, u: String): Boolean {
16            w.loadUrl(u)
17            return true
18        }
19
20        override fun onPageFinished(view: WebView?, url: String?) {
21            if (url?.contains("success?token=") ?: false) {
22                // сохраняем токен для последующих запросов
23                val token = URL(url).param("token").orEmpty()
24
25                dialog.close()
26
27                savedToken = token
28                handler.onSuccess(AuthResponse(token = token))
29            }
30
31            Log.d(TAG, url.orEmpty())
32        }
33    }
34
35    dialog.open()
36}

В этом методе мы рисуем диалог с помощью класса Dialog из пакета utils. Вся логика работы с WebView собрана внутри этого класса. Когда пользователь вводит правильную почту и пароль, он отправляется на success URL и мы можем вытащить token из GET параметров. Этот токен нам нужен для процесса покупки и получения списка платежей

Список покупок

Единственный метод, где нам не нужна авторизация для запроса

 1fun products(handler: ProductsHandler) {
 2    val request = Request.Builder()
 3        .url("${api}/api/collections/products/records?filter=${URLEncoder.encode("(application='${app}' && enabled=true)", "utf-8")}")
 4        .build()
 5
 6    client.newCall(request).enqueue(object : Callback {
 7        override fun onFailure(call: Call, e: IOException) {
 8            handler.onFailure(e)
 9        }
10
11        override fun onResponse(call: Call, response: Response) {
12            response.use {
13                if (!response.isSuccessful) {
14                    if (response.code == 403) {
15                        savedToken = ""
16                    }
17
18                    handler.onFailure(IOException("Unexpected code $response"))
19                    return
20                }
21
22                val body = response.body?.string().orEmpty()
23                val resp: ProductsResponse
24
25                try {
26                    resp = Gson().fromJson(body, ProductsResponse::class.java)
27                } catch (e: Exception) {
28                    Log.d(TAG, e.message.orEmpty())
29                    handler.onFailure(e)
30                    return
31                }
32
33                handler.onSuccess(resp)
34            }
35        }
36    })
37}

token сохраняем в памяти приложения и будет использоваться в заголовках авторизации.

Для перехвата данных в WebView нужно указывать кастомную реализацию WebViewClient и определять три метода

 1object : WebViewClient() {
 2    override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
 3        return super.shouldInterceptRequest(view, request)
 4    }
 5
 6    override fun shouldOverrideUrlLoading(w: WebView, u: String): Boolean {
 7        w.loadUrl(u)
 8        return true
 9    }
10
11    override fun onPageFinished(view: WebView?, url: String?) {
12        // тут получаем нужную информацию
13    }
14}

Обратите внимание, что в запросе указывается фильтр (application='${app}' && enabled=true) чтобы запрос вернул только активный список продуктов для конкретного приложения. Информация из запроса парсится в структуру ProductsResponse

1data class Product(
2    val id: String, 
3    val name: String, 
4    val description: String, 
5    val price: Float
6)
7
8data class ProductsResponse(val items: List<Product>)

Список продуктов отдается в коллбек, дальше их можно отобразить в приложении

Процесс покупки

Самое интересное - процесс покупки. Нужно сначала провести пользователя через авторизацию, а затем показывать форму оплаты

 1fun purchase(id: String, handler: PurchaseHandler) {
 2    // показываем диалог аторизации если нет сохраненного токена
 3    auth(object : AuthHandler {
 4        override fun onFailure(e: Throwable) {
 5            handler.onFailure(e)
 6        }
 7
 8        override fun onSuccess(resp: AuthResponse) {
 9            val token = resp.token
10
11            // показываем диалог с запросом оплаты
12            val dialog = Dialog(context = context, url = "${api}/v1/${app}/${id}/payments/purchase")
13            dialog.client = object : WebViewClient() {
14                override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
15                    val url = request!!.url.toString()
16                    return if (url.contains("${api}/v1/${app}/${id}/payments")) {
17                        // устанавливаем token в заголовок для авторизации
18                        // ...
19                    } else {
20                        super.shouldInterceptRequest(view, request)
21                    }
22                }
23
24                override fun shouldOverrideUrlLoading(w: WebView, u: String): Boolean {
25                    w.loadUrl(u)
26                    return true
27                }
28
29                override fun onPageFinished(view: WebView?, url: String?) {
30                    if (url?.contains("success?payment=") ?: false) {
31                        // проверяем платеж после редиректа на successURL
32                        // ...
33
34                        dialog.close()
35                    }
36                }
37            }
38
39            dialog.open()
40        }
41    })
42}

Я значительно сократил код выше, но его логика должна быть ясна. Сначала показываем диалог авторизации и получаем токен. Далее открываем новый диалог и делаем запрос на ручку purchase с указанием авторизации

1val request = Request.Builder()
2    .url(url.trim())
3    .addHeader("Authorization", "${token}")
4    .build()

Результат работы метода purchase отдается в коллбек handler: PurchaseHandler

1interface PurchaseHandler {
2    fun onFailure(e: Throwable)
3    fun onSuccess(resp: PurchaseResponse)
4}

Если все окей, то вызывается метод onSuccess и отдается структура PurchaseResponse

1data class PurchaseResponse(
2    val id: String,
3    val status: String,
4    val product: String,
5    val name: String,
6    val description: String,
7    val amount: Float,
8)

В этой структуре вся нужная информация для выдачи товара внутри приложения

Список платежей

Метод для получения списка платежей работает аналогично методу покупки - первым делом надо провести пользователя через авторизацию и получить токен

При таком подходе, список платежей больше поход на метод восстановления покупок в AppStore.

 1fun restore(handler: RestoreHandler) {
 2    auth(object : AuthHandler {
 3        override fun onFailure(e: Throwable) {
 4            handler.onFailure(e)
 5        }
 6
 7        override fun onSuccess(resp: AuthResponse) {
 8            val token = resp.token
 9
10            val request = Request.Builder()
11                .url("${api}/api/collections/payments/records?sort=-created")
12                .addHeader("Authorization", "${token}")
13                .build()
14
15            client.newCall(request).enqueue(object : Callback {
16                override fun onFailure(call: Call, e: IOException) {
17                    handler.onFailure(e)
18                }
19
20                override fun onResponse(call: Call, response: Response) {
21                    response.use {
22                        // ...
23
24                        handler.onSuccess(resp)
25                    }
26                }
27            })
28        }
29    })
30}

В итоге, список платежей отдается в коллбеке как структура RestoreResponse

 1data class Payment(
 2    val id: String,
 3    val status: String,
 4    val product: String,
 5    val name: String,
 6    val description: String,
 7    val amount: Float,
 8)
 9
10data class RestoreResponse(val items: List<Payment>)

Важно учитывать, что вы не можете получить список платежей просто так. Если пользователь не авторизован, то ему покажется диалог для авторизации. Поэтому восстановление покупок нужно правильно встраивать в приложениию Лучше всего добавить на своей стороне логику сохранения платежей/покупок

Как использовать

Чтобы встроить такую альтернативную систему оплаты в приложение, нужно поднять бекенд сервиса на своем сервере и интегрировать SDK в приложение

Запускаем сервер

Сервис собран в Docker образ и опубликован на gitflic. Чтобы сразу попробовать сервис в работе достаточно выполнить одну команду

1docker run -v /home/user/data:/data  -p 8080:8080 registry.gitflic.ru/project/kovardin/paygo/paygo:latest --dir /data --dev  --http :8080 serve 

Только вместо /home/user/data укажите путь к существующей папке. Сервис запустится и накатит все нужные миграции

Подключаем SDK

SDK доступно в репозитории depot.kovardin.ru. Чтобы подключить его в приложение нужно, нужно указать данные для gradle

1repositories {
2    maven {
3        name = "depot"
4        url = "https://depot.kovardin.ru/packages"
5    }
6}

И теперь можно подключать зависимость

1dependencies {
2    implementation 'ru.kovardin:billing:0.1.3'
3}

Осталось инициализировать SDK и начать пользоваться

1Billing.init(
2    context = this,
3    app = "xxxxxx",
4    api = "http://10.0.2.2:8080",
5)

xxxxxx - Идентификатор приложения в сервисе http://10.0.2.2:8080 - Это прокси на localhost хостовой машины. Укажите ваш URL на котором запущен сервис

Заключение

Статья получилась довольно обширной. Я старался подробно расписать принцип работы с платежкой, которая изначально не предназначалась для оплаты товаров в приложениях. Такой же подход можно использовать для работы с почти любой сторонней системой. Вся логика делится на три этапа

  • Создание и авторизация пользвателя
  • Показ формы оплаты со всей необходимой информацией
  • Получение подтверждения платежа

Ссылки