Compare commits

..

10 Commits

34 changed files with 1704 additions and 27 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(helm template:*)"
]
}
}

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM golang:1.26-alpine3.23 AS build
WORKDIR /payoutsbuild
RUN apk add --update --no-cache ca-certificates git
ENV GOBIN=/payoutsbuild/bin
ADD . /payoutsbuild
RUN cd /payoutsbuild && \
go mod download && \
go build ./cmd/...
FROM alpine:3.23
WORKDIR /app
COPY --from=build /payoutsbuild/payouts /app/
COPY --from=build /payoutsbuild/config/payouts.properties /app/
EXPOSE 8080
ENTRYPOINT [ "/app/payouts" ]

398
README.md Normal file
View File

@@ -0,0 +1,398 @@
# Payouts Service
A Go service for processing payouts via YooKassa, supporting SBP, YooMoney, bank card, and widget-based payout flows.
---
## API Reference
### `GET /health`
Health check endpoint. Verifies database connectivity.
**Request parameters:** None
**Response:**
| Status | Body |
|--------|------|
| `200 OK` | `{"OK": true}` |
| `503 Service Unavailable` | `{"OK": false, "Error": "error details"}` |
**Example:**
```bash
curl -s http://localhost:8080/health
```
---
### `GET /version`
Returns the application version.
**Request parameters:** None
**Response:** Plain text version string (e.g. `v1.0.0`). If version is `unknown`, the git commit hash from build info is appended.
**Example:**
```bash
curl -s http://localhost:8080/version
```
---
### `POST /api/v1/user/register`
Register a new user.
**Request body (JSON):**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `tin` | string | yes | Tax identification number |
| `phone` | string | yes | Phone number (must be unique) |
| `password` | string | yes | Password |
| `password_cfm` | string | yes | Password confirmation (must match `password`) |
**Response:**
| Status | Description |
|--------|-------------|
| `201 Created` | User registered successfully (no body) |
| `400 Bad Request` | Validation error or phone already registered |
| `500 Internal Server Error` | Password hashing failure |
**Example:**
```bash
curl -s -X POST http://localhost:8080/api/v1/user/register \
-H "Content-Type: application/json" \
-d '{"tin":"123456789","phone":"+79001234567","password":"secret","password_cfm":"secret"}'
```
---
### `POST /api/v1/user/login`
Authenticate a user and obtain a session token.
**Request body (JSON):**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `phone` | string | yes | Registered phone number |
| `password` | string | yes | Password |
**Response (200 OK):**
```json
{
"token": "550e8400-e29b-41d4-a716-446655440000",
"token_ttl": 1712000000
}
```
| Field | Type | Description |
|-------|------|-------------|
| `token` | string | UUID session token to use in subsequent requests |
| `token_ttl` | integer | Unix timestamp when the token expires |
**Error response:**
```json
{
"status": 401,
"message": "Unauthorized"
}
```
**Example:**
```bash
curl -s -X POST http://localhost:8080/api/v1/user/login \
-H "Content-Type: application/json" \
-d '{"phone":"+79001234567","password":"secret"}'
```
---
### `GET /api/v1/payout/sbp/banks`
Retrieve the list of banks available for SBP payouts.
**Request parameters:** None
**Authentication:** Not required
**Response (200 OK):**
```json
{
"type": "sbp_banks",
"items": [
{
"bank_id": "100000000111",
"name": "Тинькофф Банк",
"bic": "044525974"
}
]
}
```
**Example:**
```bash
curl -s http://localhost:8080/api/v1/payout/sbp/banks
```
---
### `POST /api/v1/payout/create`
Create a payout. The `payout_type` determines which additional fields are required.
**Request headers:**
| Header | Required | Description |
|--------|----------|-------------|
| `Authorization` | yes | `Bearer {token}` — session token from login |
| `Content-Type` | yes | `application/json` |
**Request body (JSON):**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `payout_type` | string | yes | One of: `spb`, `yoo_money`, `bank_card`, `widget` |
| `amount` | float | yes | Payout amount in rubles |
| `payout_token` | string | for `widget` | Token received from the YooKassa widget `success_callback` |
| `account_number` | string | for `yoo_money` | YooMoney wallet number or phone |
| `bank_id` | string | for `spb` | Bank identifier from `/api/v1/payout/sbp/banks` |
| `card_number` | string | for `bank_card` | Card number |
> **Note:** For `spb`, the phone number is taken from the authenticated user's profile.
**Response (200 OK):**
```json
{
"payout_id": "po-285e5ee7-0022-5000-8000-01516a44b37d",
"payout_status": "succeeded"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `payout_id` | string | YooKassa payout identifier |
| `payout_status` | string | One of: `created`, `pending`, `succeeded`, `canceled`, `failed` |
**Error response:**
```json
{
"status": 401,
"message": "Unauthorized"
}
```
**Example (SBP payout):**
```bash
curl -s -X POST http://localhost:8080/api/v1/payout/create \
-H "Authorization: Bearer 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{"payout_type":"spb","amount":500.00,"bank_id":"100000000111"}'
```
**Example (widget payout):**
```bash
curl -s -X POST http://localhost:8080/api/v1/payout/create \
-H "Authorization: Bearer 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{"payout_type":"widget","amount":500.00,"payout_token":"pt-285e5ee7-0022-5000-8000-01516a44b37d"}'
```
---
### `POST /api/v1/payout/callback`
Webhook endpoint for YooKassa payout status notifications. Called by YooKassa when a payout status changes.
> **Note:** When `YooKassa.CheckAllowedCallbackAddress = true`, requests are validated against a CIDR whitelist of YooKassa IP ranges.
**Request body (JSON, sent by YooKassa):**
```json
{
"id": "po-285e5ee7-0022-5000-8000-01516a44b37d",
"status": "succeeded",
"amount": {
"value": "500.00",
"currency": "RUB"
},
"payout_destination": {
"type": "bank_card",
"card": {
"number": "220000******0001",
"first6": "220000",
"last4": "0001",
"card_type": "MIR",
"issuer_country": "RU",
"issuer_name": "Sberbank"
}
},
"description": "Payout description",
"created_at": "2024-01-01T12:00:00.000Z",
"succeeded_at": "2024-01-01T12:00:05.000Z",
"test": false
}
```
**Response:** `200 OK` (processing is asynchronous)
**Example:**
```bash
curl -s -X POST http://localhost:8080/api/v1/payout/callback \
-H "Content-Type: application/json" \
-d '{"id":"po-285e5ee7-0022-5000-8000-01516a44b37d","status":"succeeded","amount":{"value":"500.00","currency":"RUB"}}'
```
---
## Payout Widget: `/payout/widget`
`GET /payout/widget` serves an HTML page that embeds the [YooKassa Payout Widget](https://yookassa.ru/developers/payouts/making-payouts/bank-card/using-payout-widget/implementing-widget). The widget collects card details from the user and returns a one-time `payout_token` that must be passed to `/api/v1/payout/create`.
### Mobile App Integration
The widget page is designed to be loaded inside a **WebView** on Android or iOS. The widget communicates back to the native app via JavaScript bridge callbacks.
#### Widget Callbacks
The widget fires two callbacks:
**`success_callback(data)`** — called when the user successfully submits card details. The `data` object contains the `payout_token` and card metadata. See [YooKassa widget output parameters](https://yookassa.ru/developers/payouts/making-payouts/bank-card/using-payout-widget/implementing-widget#reference-output-parameters).
**`error_callback(error)`** — called when an error occurs in the widget. See [error output parameters](https://yookassa.ru/developers/payouts/making-payouts/bank-card/using-payout-widget/implementing-widget#reference-output-parameters-error).
#### Android
Expose a JavaScript interface named `AndroidCallback` on the WebView:
```kotlin
class AndroidBridge {
@JavascriptInterface
fun onWidgetData(dataJson: String) {
val data = JSONObject(dataJson)
val payoutToken = data.getString("payout_token")
// Call /api/v1/payout/create with payout_type "widget"
createPayout(payoutToken = payoutToken, amount = 500.0)
}
@JavascriptInterface
fun onWidgetError(errorJson: String) {
// Handle widget error
}
}
webView.addJavascriptInterface(AndroidBridge(), "AndroidCallback")
```
#### iOS (WKWebView)
Add a `WKScriptMessageHandler` named `iosCallback`:
```swift
class WidgetMessageHandler: NSObject, WKScriptMessageHandler {
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard let body = message.body as? String,
let data = body.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return }
if message.name == "onWidgetData" {
let payoutToken = json["payout_token"] as? String ?? ""
// Call /api/v1/payout/create with payout_type "widget"
createPayout(payoutToken: payoutToken, amount: 500.0)
} else if message.name == "onWidgetError" {
// Handle widget error
}
}
}
let contentController = WKUserContentController()
let handler = WidgetMessageHandler()
contentController.add(handler, name: "onWidgetData")
contentController.add(handler, name: "onWidgetError")
```
#### Payout Flow After Widget Callback
When `onWidgetData` fires, call `/api/v1/payout/create` with `payout_type = "widget"` and the received `payout_token`:
```bash
curl -s -X POST https://your-service/api/v1/payout/create \
-H "Authorization: Bearer {session_token}" \
-H "Content-Type: application/json" \
-d '{
"payout_type": "widget",
"amount": 500.00,
"payout_token": "{payout_token_from_widget}"
}'
```
---
## Configuration
Configuration is loaded from a `.properties` file (default: `config/payouts.properties`).
### YooKassa
| Property | Default | Description |
|----------|---------|-------------|
| `YooKassa.BaseUrl` | `https://api.yookassa.ru/v3` | YooKassa API base URL |
| `YooKassa.Timeout` | `2s` | HTTP request timeout |
| `YooKassa.Test` | `false` | Enable test mode |
| `YooKassa.ApiBaseKey` | — | Base API key (used for SBP bank list) |
| `YooKassa.ApiBaseSecret` | — | Base API secret |
| `YooKassa.ApiPayoutKey` | — | Payouts API key (gateway account ID; also used as `account_id` in the widget) |
| `YooKassa.ApiPayoutSecret` | — | Payouts API secret |
| `YooKassa.Retry.Enabled` | `false` | Enable automatic request retries |
| `YooKassa.Retry.Count` | `3` | Total attempt count (including the initial request) |
| `YooKassa.Retry.WaitTime` | `200ms` | Initial delay between retries |
| `YooKassa.Retry.MaxWaitTime` | `5s` | Maximum delay (exponential backoff cap) |
| `YooKassa.CheckAllowedCallbackAddress` | `true` | Validate callback source IP against whitelist |
| `YooKassa.AllowedCallbackSubnets` | YooKassa IP ranges | Comma-separated CIDR subnets allowed to send callbacks |
| `YooKassa.CallbackProcessTimeout` | `1s` | Timeout for async callback processing |
| `YooKassa.WidgetVersion` | `3.1.0` | YooKassa widget JS version loaded on `/payout/widget` |
### Database
| Property | Default | Description |
|----------|---------|-------------|
| `Database.Type` | — | Database driver: `sqlite` or `postgres` |
| `Database.Connection` | — | Connection string. SQLite: `payouts.db`. PostgreSQL: `host=127.0.0.1 user=gorm password=gorm dbname=gorm port=5432 sslmode=disable` |
| `Database.LogLevel` | `Info` | GORM log level: `Debug`, `Info`, `Warn`, `Error` |
| `Database.TraceRequests` | `false` | Log all SQL queries |
### Session Cache
| Property | Default | Description |
|----------|---------|-------------|
| `Cache.TTL` | `24h` | Session token time-to-live (Go duration string) |
### Server
| Property | Default | Description |
|----------|---------|-------------|
| `Server.Port` | `:8080` | Listening address and port |
| `Server.WriteTimeout` | `35s` | Response write timeout |
| `Server.ReadTimeout` | `35s` | Request read timeout |
| `Server.EnablePProfEndpoints` | `false` | Expose `/debug/pprof` endpoints |
| `Server.Tls.Enabled` | `false` | Enable TLS |
| `Server.Tls.CertFile` | — | Path to TLS certificate file |
| `Server.Tls.KeyFile` | — | Path to TLS private key file |
### Logging
| Property | Default | Description |
|----------|---------|-------------|
| `Log.Level` | `DEBUG` | Log level: `DEBUG`, `INFO`, `WARN`, `ERROR` |
| `Log.FilePath` | `./logs/payouts.log` | Log file path |
| `Log.TextOutput` | `false` | Use plain text output instead of JSON |
| `Log.StdoutEnabled` | `true` | Write logs to stdout |
| `Log.FileEnabled` | `false` | Write logs to file |

View File

@@ -3,10 +3,9 @@ Server.WriteTimeout = 35s
Server.ReadTimeout = 35s Server.ReadTimeout = 35s
Server.EnablePProfEndpoints = false Server.EnablePProfEndpoints = false
Socket.MaxHttpBufferSize = 2097152 Server.Tls.Enabled = false
Socket.PingInterval = 25s Server.Tls.CertFile =
Socket.PingTimeout = 20s Server.Tls.KeyFile =
Socket.Debug = false
# Prometheus settings # Prometheus settings
Metrics.Endpoint = /metrics Metrics.Endpoint = /metrics
@@ -56,8 +55,10 @@ YooKassa.AllowedCallbackSubnets = 185.71.76.0/27,185.71.77.0/27,77.75.153.0/25,7
# Base API key/secret # Base API key/secret
YooKassa.ApiBaseKey = YooKassa.ApiBaseKey =
YooKassa.ApiBaseSecret = YooKassa.ApiBaseSecret =
# Payments API key/secret # Payouts API key/secret
YooKassa.ApiPaymentKey = YooKassa.ApiPayoutKey =
YooKassa.ApiPaymentSecret = YooKassa.ApiPayoutSecret =
# Timeout to process yookassa callback # Timeout to process yookassa callback
YooKassa.CallbackProcessTimeout = 1s YooKassa.CallbackProcessTimeout = 1s
# Widget version
YooKassa.WidgetVersion = 3.1.0

6
helm/payouts/Chart.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: payouts
description: Payouts service Helm chart
type: application
version: 0.1.0
appVersion: "1.0.0"

203
helm/payouts/README.md Normal file
View File

@@ -0,0 +1,203 @@
# payouts Helm Chart
Helm chart for deploying the **payouts** service to Kubernetes.
## Prerequisites
- Kubernetes 1.21+
- Helm 3.2+
## Installing the Chart
```bash
helm install payouts ./helm
```
To install into a specific namespace:
```bash
helm install payouts ./helm --namespace payouts --create-namespace
```
## Uninstalling the Chart
```bash
helm uninstall payouts
```
## Configuration
All parameters are set via `values.yaml` or `--set` flags.
### Image
| Parameter | Description | Default |
|-----------|-------------|---------|
| `image.repository` | Container image repository | `payouts` |
| `image.tag` | Container image tag | `latest` |
| `image.pullPolicy` | Image pull policy | `IfNotPresent` |
### Service
| Parameter | Description | Default |
|-----------|-------------|---------|
| `service.type` | Kubernetes service type | `ClusterIP` |
| `service.port` | Service port | `8080` |
### Ingress
| Parameter | Description | Default |
|-----------|-------------|---------|
| `ingress.enabled` | Enable ingress | `false` |
| `ingress.className` | IngressClass name | `""` |
| `ingress.annotations` | Ingress annotations | `{}` |
| `ingress.host` | Ingress hostname | `payouts.example.com` |
| `ingress.path` | Ingress path | `/` |
| `ingress.pathType` | Ingress path type | `Prefix` |
| `ingress.tls` | TLS configuration | `[]` |
### TLS (application-level)
When `config.Server.Tls.Enabled` is `true` the chart mounts a TLS secret as
files into the pod at the paths defined by `config.Server.Tls.CertFile` and
`config.Server.Tls.KeyFile`.
Two modes are supported:
**Option A — use an existing secret** (type `kubernetes.io/tls`):
```yaml
config:
Server:
Tls:
Enabled: true
tls:
existingSecret: "my-tls-secret"
```
**Option B — let the chart create the secret** (supply PEM values, do not commit to VCS):
```yaml
config:
Server:
Tls:
Enabled: true
tls:
cert: |
-----BEGIN CERTIFICATE-----
...
key: |
-----BEGIN PRIVATE KEY-----
...
```
| Parameter | Description | Default |
|-----------|-------------|---------|
| `config.Server.Tls.Enabled` | Enable TLS on the HTTP server | `false` |
| `config.Server.Tls.CertFile` | Path to the certificate file inside the pod | `/etc/payouts/tls/tls.crt` |
| `config.Server.Tls.KeyFile` | Path to the private key file inside the pod | `/etc/payouts/tls/tls.key` |
| `tls.existingSecret` | Name of an existing `kubernetes.io/tls` secret to use | `""` |
| `tls.cert` | PEM-encoded certificate (used when `existingSecret` is empty) | `""` |
| `tls.key` | PEM-encoded private key (used when `existingSecret` is empty) | `""` |
> When TLS is enabled, either `tls.existingSecret` or both `tls.cert` and `tls.key` must be provided — the chart will fail with a descriptive error otherwise.
### Application Config
Non-secret application parameters are stored in a ConfigMap and mounted as
`/etc/payouts/config.yaml` inside the pod. The path is passed to the application
via the `CONFIG_PATH` environment variable.
All keys preserve the exact casing from `config/payouts.properties`.
| Parameter | Description | Default |
|-----------|-------------|---------|
| `config.Server.Port` | HTTP listen address | `:8080` |
| `config.Server.WriteTimeout` | HTTP write timeout | `35s` |
| `config.Server.ReadTimeout` | HTTP read timeout | `35s` |
| `config.Server.EnablePProfEndpoints` | Expose pprof endpoints | `false` |
| `config.Socket.MaxHttpBufferSize` | Max HTTP buffer size for socket | `2097152` |
| `config.Socket.PingInterval` | Socket ping interval | `25s` |
| `config.Socket.PingTimeout` | Socket ping timeout | `20s` |
| `config.Socket.Debug` | Enable socket debug logging | `false` |
| `config.Metrics.Endpoint` | Prometheus metrics endpoint | `/metrics` |
| `config.Metrics.HistogramBuckets` | Global histogram buckets | `0.001,...,10` |
| `config.Metrics.Http.HistogramEnabled` | Enable HTTP latency histogram | `true` |
| `config.Metrics.Http.Buckets` | HTTP histogram buckets | `0.001,...,10` |
| `config.Log.Level` | Log level | `DEBUG` |
| `config.Log.FilePath` | Log file path | `./logs/payouts.log` |
| `config.Log.TextOutput` | Use plain-text log format | `false` |
| `config.Log.StdoutEnabled` | Log to stdout | `true` |
| `config.Log.FileEnabled` | Log to file | `false` |
| `config.Database.Type` | Database type (`sqlite` or `postgres`) | `""` |
| `config.Database.LogLevel` | Database query log level | `Info` |
| `config.Database.TraceRequests` | Trace all DB requests | `false` |
| `config.Cache.TTL` | Session cache TTL | `24h` |
| `config.YooKassa.BaseUrl` | YooKassa API base URL | `https://api.yookassa.ru/v3` |
| `config.YooKassa.Timeout` | YooKassa request timeout | `2s` |
| `config.YooKassa.Retry.Enabled` | Enable request retries | `false` |
| `config.YooKassa.Retry.Count` | Retry count (incl. initial) | `3` |
| `config.YooKassa.Retry.WaitTime` | Wait time between retries | `200ms` |
| `config.YooKassa.Retry.MaxWaitTime` | Max wait time (exponential backoff) | `5s` |
| `config.YooKassa.Test` | Enable YooKassa test mode | `false` |
| `config.YooKassa.CheckAllowedCallbackAddress` | Validate callback source IP | `true` |
| `config.YooKassa.AllowedCallbackSubnets` | Allowed callback CIDR list | YooKassa subnets |
| `config.YooKassa.CallbackProcessTimeout` | Timeout to process callback | `1s` |
### Secrets
Secret values are stored in a Kubernetes Secret and injected as environment
variables. Variable names are the uppercased property key with `.` replaced by `_`.
> **These values are empty by default and must be supplied before deploying to production.**
| Env variable | Original property |
|---|---|
| `DATABASE_CONNECTION` | `Database.Connection` |
| `YOOKASSA_APIBASEKEY` | `YooKassa.ApiBaseKey` |
| `YOOKASSA_APIBASESECRET` | `YooKassa.ApiBaseSecret` |
| `YOOKASSA_APIPAYOUTKEY` | `YooKassa.ApiPayoutKey` |
| `YOOKASSA_APIPAYOUTSECRET` | `YooKassa.ApiPayoutSecret` |
Provide secrets at install/upgrade time:
```bash
helm install payouts ./helm \
--set secrets.DATABASE_CONNECTION="host=127.0.0.1 user=app password=s3cr3t dbname=payouts port=5432 sslmode=disable" \
--set secrets.YOOKASSA_APIBASEKEY="<key>" \
--set secrets.YOOKASSA_APIBASESECRET="<secret>" \
--set secrets.YOOKASSA_APIPAYOUTKEY="<key>" \
--set secrets.YOOKASSA_APIPAYOUTSECRET="<secret>"
```
Or keep them in a separate values file that is **not committed to version control**:
```bash
helm install payouts ./helm -f secrets.values.yaml
```
Example `secrets.values.yaml`:
```yaml
secrets:
DATABASE_CONNECTION: "host=127.0.0.1 user=payouts password=password dbname=payouts port=5432 sslmode=disable"
YOOKASSA_APIBASEKEY: "<key>"
YOOKASSA_APIBASESECRET: "<secret>"
YOOKASSA_APIPAYOUTKEY: "<key>"
YOOKASSA_APIPAYOUTSECRET: "<secret>"
```
### Ingress example
```yaml
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
host: payouts.example.com
tls:
- secretName: payouts-tls
hosts:
- payouts.example.com
```

View File

@@ -0,0 +1,45 @@
1. Get the application URL:
{{- if .Values.ingress.enabled }}
http{{ if .Values.ingress.tls }}s{{ end }}://{{ .Values.ingress.host }}{{ .Values.ingress.path }}
{{- else if eq .Values.service.type "NodePort" }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "payouts.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo "http://$NODE_IP:$NODE_PORT"
{{- else if eq .Values.service.type "LoadBalancer" }}
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "payouts.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo "http://$SERVICE_IP:{{ .Values.service.port }}"
{{- else }}
kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "payouts.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }}
echo "http://127.0.0.1:{{ .Values.service.port }}"
{{- end }}
2. Configuration is mounted at $CONFIG_PATH inside the pod:
/etc/payouts/config.yaml (from ConfigMap {{ include "payouts.fullname" . }})
{{- if .Values.config.Server.Tls.Enabled }}
3. TLS is ENABLED. Certificate and key are mounted from Secret {{ include "payouts.tlsSecretName" . }}:
{{ .Values.config.Server.Tls.CertFile }}
{{ .Values.config.Server.Tls.KeyFile }}
{{- if not .Values.tls.existingSecret }}
The chart created the TLS secret. To rotate the certificate, update tls.cert / tls.key and run helm upgrade.
{{- else }}
Using existing secret: {{ .Values.tls.existingSecret }}
{{- end }}
4. Secret environment variables are injected from Secret {{ include "payouts.fullname" . }}:
{{- else }}
3. Secret environment variables are injected from Secret {{ include "payouts.fullname" . }}:
{{- end }}
DATABASE_CONNECTION, YOOKASSA_APIBASEKEY, YOOKASSA_APIBASESECRET,
YOOKASSA_APIPAYOUTKEY, YOOKASSA_APIPAYOUTSECRET
Before deploying to production, populate these values:
helm upgrade {{ .Release.Name }} ./helm \
--set secrets.DATABASE_CONNECTION="host=... dbname=..." \
--set secrets.YOOKASSA_APIBASEKEY="<key>" \
--set secrets.YOOKASSA_APIBASESECRET="<secret>" \
--set secrets.YOOKASSA_APIPAYOUTKEY="<key>" \
--set secrets.YOOKASSA_APIPAYOUTSECRET="<secret>"
Or use a separate values file that is not committed to version control:
helm upgrade {{ .Release.Name }} ./helm -f secrets.values.yaml

View File

@@ -0,0 +1,62 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "payouts.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "payouts.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "payouts.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{ include "payouts.selectorLabels" . }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "payouts.selectorLabels" -}}
app.kubernetes.io/name: {{ include "payouts.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Name of the TLS secret to mount.
Returns tls.existingSecret when set, otherwise the chart-managed secret name.
*/}}
{{- define "payouts.tlsSecretName" -}}
{{- if .Values.tls.existingSecret }}
{{- .Values.tls.existingSecret }}
{{- else }}
{{- printf "%s-tls" (include "payouts.fullname" .) }}
{{- end }}
{{- end }}
{{/*
Validate TLS configuration.
*/}}
{{- define "payouts.validateTls" -}}
{{- if .Values.config.Server.Tls.Enabled }}
{{- if and (not .Values.tls.existingSecret) (or (not .Values.tls.cert) (not .Values.tls.key)) }}
{{- fail "TLS is enabled: either set tls.existingSecret or provide both tls.cert and tls.key" }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,9 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "payouts.fullname" . }}
labels:
{{- include "payouts.labels" . | nindent 4 }}
data:
config.yaml: |
{{- .Values.config | toYaml | nindent 4 }}

View File

@@ -0,0 +1,85 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "payouts.fullname" . }}
labels:
{{- include "payouts.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "payouts.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "payouts.selectorLabels" . | nindent 8 }}
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- if .Values.config.Server.Tls.Enabled }}
checksum/tls: {{ include (print $.Template.BasePath "/tls-secret.yaml") . | sha256sum }}
{{- end }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: CONFIG_PATH
value: /etc/payouts/config.yaml
{{- range $key, $value := .Values.secrets }}
- name: {{ $key }}
valueFrom:
secretKeyRef:
name: {{ include "payouts.fullname" $ }}
key: {{ $key }}
{{- end }}
ports:
- name: http
containerPort: {{ trimPrefix ":" .Values.config.Server.Port | int }}
protocol: TCP
livenessProbe:
httpGet:
path: /health
port: http
readinessProbe:
httpGet:
path: /health
port: http
volumeMounts:
- name: config
mountPath: /etc/payouts
readOnly: true
{{- if .Values.config.Server.Tls.Enabled }}
- name: tls
mountPath: /etc/payouts/tls
readOnly: true
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:
- name: config
configMap:
name: {{ include "payouts.fullname" . }}
{{- if .Values.config.Server.Tls.Enabled }}
- name: tls
secret:
secretName: {{ include "payouts.tlsSecretName" . }}
items:
- key: tls.crt
path: tls.crt
- key: tls.key
path: tls.key
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,31 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "payouts.fullname" . }}
labels:
{{- include "payouts.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: {{ .Values.ingress.path }}
pathType: {{ .Values.ingress.pathType }}
backend:
service:
name: {{ include "payouts.fullname" . }}
port:
name: http
{{- end }}

View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ include "payouts.fullname" . }}
labels:
{{- include "payouts.labels" . | nindent 4 }}
type: Opaque
data:
{{- range $key, $value := .Values.secrets }}
{{ $key }}: {{ $value | toString | b64enc | quote }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "payouts.fullname" . }}
labels:
{{- include "payouts.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "payouts.selectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,13 @@
{{- if and .Values.config.Server.Tls.Enabled (not .Values.tls.existingSecret) }}
{{- include "payouts.validateTls" . }}
apiVersion: v1
kind: Secret
metadata:
name: {{ printf "%s-tls" (include "payouts.fullname" .) }}
labels:
{{- include "payouts.labels" . | nindent 4 }}
type: kubernetes.io/tls
data:
tls.crt: {{ .Values.tls.cert | toString | b64enc }}
tls.key: {{ .Values.tls.key | toString | b64enc }}
{{- end }}

105
helm/payouts/values.yaml Normal file
View File

@@ -0,0 +1,105 @@
replicaCount: 1
image:
repository: payouts
tag: latest
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 8080
ingress:
enabled: false
className: ""
annotations: {}
host: payouts.example.com
path: /
pathType: Prefix
tls: []
resources: {}
nodeSelector: {}
tolerations: []
affinity: {}
# Non-secret application config — rendered as /etc/payouts/config.yaml inside the pod
config:
Server:
Port: ":8080"
WriteTimeout: 35s
ReadTimeout: 35s
EnablePProfEndpoints: false
Tls:
Enabled: false
CertFile: /etc/payouts/tls/tls.crt
KeyFile: /etc/payouts/tls/tls.key
Socket:
MaxHttpBufferSize: 2097152
PingInterval: 25s
PingTimeout: 20s
Debug: false
Metrics:
Endpoint: /metrics
HistogramBuckets: "0.001,0.002,0.005,0.01,0.025,0.05,0.1,0.25,0.5,1,2.5,5,10"
Http:
HistogramEnabled: true
Buckets: "0.001,0.002,0.005,0.01,0.025,0.05,0.1,0.25,0.5,1,2.5,5,10"
Log:
Level: DEBUG
FilePath: ./logs/payouts.log
TextOutput: false
StdoutEnabled: true
FileEnabled: false
Database:
Type: ""
LogLevel: Info
TraceRequests: false
Cache:
TTL: 24h
YooKassa:
BaseUrl: https://api.yookassa.ru/v3
Timeout: 2s
Retry:
Enabled: false
Count: 3
WaitTime: 200ms
MaxWaitTime: 5s
Test: false
CheckAllowedCallbackAddress: true
AllowedCallbackSubnets: "185.71.76.0/27,185.71.77.0/27,77.75.153.0/25,77.75.156.11/32,77.75.156.35/32,77.75.154.128/25,2a02:5180::/32"
CallbackProcessTimeout: 1s
# TLS for the application server
# When config.Server.Tls.Enabled is true, a volume with the cert and key is mounted
# into the pod at the paths defined by config.Server.Tls.CertFile / KeyFile.
#
# Option A — bring your own secret (must be type kubernetes.io/tls):
# tls.existingSecret: "my-tls-secret"
#
# Option B — supply PEM values and let the chart create the secret:
# tls.cert: |
# -----BEGIN CERTIFICATE-----
# ...
# tls.key: |
# -----BEGIN PRIVATE KEY-----
# ...
tls:
existingSecret: ""
cert: ""
key: ""
# Secret values — injected as env vars (uppercase, dots → underscores)
secrets:
DATABASE_CONNECTION: ""
YOOKASSA_APIBASEKEY: ""
YOOKASSA_APIBASESECRET: ""
YOOKASSA_APIPAYOUTKEY: ""
YOOKASSA_APIPAYOUTSECRET: ""

View File

@@ -0,0 +1,46 @@
package health
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"payouts/internal/service/database"
)
// Route health route
const Route = "/health"
// New constructs a new health Handler.
func New(dbService database.Service) (Handler, error) {
return &handler{
dbService: dbService,
}, nil
}
type handler struct {
dbService database.Service
}
// HealthHandler handles the health check requests
func (h *handler) Health(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
status := map[string]any{}
// Check database connection
err := h.dbService.HealthCheck()
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
status["Error"] = fmt.Sprintf("%v", err)
slog.Error("Health check failed", slog.String("error", status["Error"].(string)))
} else {
w.WriteHeader(http.StatusOK)
}
status["OK"] = (err == nil)
encoder := json.NewEncoder(w)
encoder.Encode(status)
}

View File

@@ -0,0 +1,16 @@
package health
import (
"net/http"
"go.uber.org/fx"
)
var Module = fx.Options(
fx.Provide(New),
)
// Handler health handler interface
type Handler interface {
Health(http.ResponseWriter, *http.Request)
}

View File

@@ -11,9 +11,11 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"go.uber.org/fx" "go.uber.org/fx"
"payouts/internal/api/health"
"payouts/internal/api/payout" "payouts/internal/api/payout"
"payouts/internal/api/user" "payouts/internal/api/user"
"payouts/internal/api/version" "payouts/internal/api/version"
"payouts/internal/api/widget"
appConfig "payouts/internal/config" appConfig "payouts/internal/config"
"payouts/internal/service/monitoring" "payouts/internal/service/monitoring"
) )
@@ -21,8 +23,10 @@ import (
// Module is a fx module // Module is a fx module
var Module = fx.Options( var Module = fx.Options(
user.Module, user.Module,
health.Module,
payout.Module, payout.Module,
version.Module, version.Module,
widget.Module,
monitoring.Module, monitoring.Module,
fx.Invoke(RegisterRoutes), fx.Invoke(RegisterRoutes),
@@ -39,7 +43,9 @@ type Params struct {
PayoutHandler payout.Handler PayoutHandler payout.Handler
UserHandler user.Handler UserHandler user.Handler
HealthHandler health.Handler
Version version.Handler Version version.Handler
Widget widget.Handler
Metrics monitoring.Metrics Metrics monitoring.Metrics
} }
@@ -50,7 +56,10 @@ func RegisterRoutes(p Params, lc fx.Lifecycle) {
router := mux.NewRouter() router := mux.NewRouter()
router.StrictSlash(true) router.StrictSlash(true)
// Version endpoint
router.HandleFunc(version.Route, p.Version.VersionHandler).Methods(http.MethodGet) router.HandleFunc(version.Route, p.Version.VersionHandler).Methods(http.MethodGet)
// Health check endpoint
router.HandleFunc(health.Route, p.HealthHandler.Health).Methods(http.MethodGet)
if p.AppConfig.Server.EnablePProfEndpoints { if p.AppConfig.Server.EnablePProfEndpoints {
router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
@@ -67,9 +76,13 @@ func RegisterRoutes(p Params, lc fx.Lifecycle) {
userRouter.HandleFunc(user.LoginRoute, p.UserHandler.UserLogin).Methods(http.MethodPost) userRouter.HandleFunc(user.LoginRoute, p.UserHandler.UserLogin).Methods(http.MethodPost)
payoutRouter := apiRouter.PathPrefix(payout.BaseRoute).Subrouter() payoutRouter := apiRouter.PathPrefix(payout.BaseRoute).Subrouter()
payoutRouter.HandleFunc(payout.BanksRoute, p.PayoutHandler.GetSbpBanks).Methods(http.MethodGet)
payoutRouter.HandleFunc(payout.CreateRoute, p.PayoutHandler.PayoutCreate).Methods(http.MethodPost) payoutRouter.HandleFunc(payout.CreateRoute, p.PayoutHandler.PayoutCreate).Methods(http.MethodPost)
payoutRouter.HandleFunc(payout.CallbackRoute, p.PayoutHandler.PayoutCallback).Methods(http.MethodPost) payoutRouter.HandleFunc(payout.CallbackRoute, p.PayoutHandler.PayoutCallback).Methods(http.MethodPost)
// Widget endpoint
router.HandleFunc(widget.WidgetPage, p.Widget.WidgetHandler).Methods(http.MethodGet)
// collect api metrics // collect api metrics
apiRouter.Use(p.Metrics.GetMiddleware()) apiRouter.Use(p.Metrics.GetMiddleware())

View File

@@ -122,7 +122,7 @@ func (p *payoutHandler) GetSbpBanks(w http.ResponseWriter, r *http.Request) {
} }
} }
// PaymentCreate implements [Handler]. // PayoutCreate implements [Handler].
func (p *payoutHandler) PayoutCreate(w http.ResponseWriter, r *http.Request) { func (p *payoutHandler) PayoutCreate(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close() defer r.Body.Close()
@@ -203,7 +203,7 @@ func (p *payoutHandler) delayedPayoutUpdate(ctx context.Context, payoutData *yoo
} }
} }
// PaymentCallback implements [Handler]. // PayoutCallback implements [Handler].
func (p *payoutHandler) PayoutCallback(w http.ResponseWriter, r *http.Request) { func (p *payoutHandler) PayoutCallback(w http.ResponseWriter, r *http.Request) {
// todo: check also the X-real-ip and/or X-Forwarded-For // todo: check also the X-real-ip and/or X-Forwarded-For
if p.yookassaConf.CheckAllowedCallbackAddress && !p.checkAllowedIpCallback(r.RemoteAddr) { if p.yookassaConf.CheckAllowedCallbackAddress && !p.checkAllowedIpCallback(r.RemoteAddr) {

View File

@@ -3,6 +3,7 @@ package version
import ( import (
"io" "io"
"net/http" "net/http"
"runtime/debug"
"go.uber.org/fx" "go.uber.org/fx"
@@ -39,7 +40,20 @@ type handler struct {
func (h *handler) VersionHandler(w http.ResponseWriter, r *http.Request) { func (h *handler) VersionHandler(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close() defer r.Body.Close()
ver := h.version
if ver == "unknown" {
buildInfo, ok := debug.ReadBuildInfo()
if ok {
for _, setting := range buildInfo.Settings {
if setting.Key == "vcs.revision" {
ver = ver + "-" + setting.Value
break
}
}
}
}
w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
io.WriteString(w, h.version) io.WriteString(w, ver+"\n")
} }

View File

@@ -0,0 +1,9 @@
package widget
import (
"go.uber.org/fx"
)
var Module = fx.Options(
fx.Provide(NewWidgetHandler),
)

View File

@@ -0,0 +1,55 @@
package widget
import (
"html/template"
"net/http"
"go.uber.org/fx"
"payouts/internal/service/yookassa"
yookassaConf "payouts/internal/service/yookassa/config"
"payouts/internal/templates"
)
const WidgetPage = "/payout/widget"
type Handler interface {
WidgetHandler(http.ResponseWriter, *http.Request)
}
type widgetHandler struct {
template *template.Template
config yookassaConf.YooKassa
}
// Params represents the module input params
type Params struct {
fx.In
YookassaService yookassa.Service
}
func NewWidgetHandler(p Params) (Handler, error) {
return &widgetHandler{
template: templates.Templates,
config: p.YookassaService.GetConfig(),
}, nil
}
// WidgetHandler renders the payouts widget page
func (h *widgetHandler) WidgetHandler(w http.ResponseWriter, r *http.Request) {
data := struct {
ApiPayoutKey string
WidgetVersion string
}{
ApiPayoutKey: h.config.ApiPayoutKey,
WidgetVersion: h.config.WidgetVersion,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
err := h.template.ExecuteTemplate(w, "payouts-widget.html", data)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}

View File

@@ -66,6 +66,9 @@ func NewAppConfig() (*App, error) {
tempConf.SetConfigName(confName) tempConf.SetConfigName(confName)
tempConf.SetConfigType(confType) tempConf.SetConfigType(confType)
tempConf.AutomaticEnv()
tempConf.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
err := tempConf.ReadInConfig() err := tempConf.ReadInConfig()
if err != nil { if err != nil {
// complain on missed non-default config // complain on missed non-default config

View File

@@ -10,6 +10,8 @@ type PayoutType int64
const ( const (
TypeSBP PayoutType = iota TypeSBP PayoutType = iota
TypeYooMoney TypeYooMoney
TypeCard
TypeCardWidget
) )
func (r PayoutType) String() string { func (r PayoutType) String() string {
@@ -18,6 +20,10 @@ func (r PayoutType) String() string {
return "spb" return "spb"
case TypeYooMoney: case TypeYooMoney:
return "yoo_money" return "yoo_money"
case TypeCard:
return "bank_card"
case TypeCardWidget:
return "widget"
} }
return "unknown" return "unknown"
} }
@@ -33,8 +39,12 @@ func (r *PayoutType) UnmarshalText(text []byte) (err error) {
*r = TypeSBP *r = TypeSBP
case "yoo_money": case "yoo_money":
*r = TypeYooMoney *r = TypeYooMoney
case "bank_card":
*r = TypeCard
case "widget":
*r = TypeCardWidget
default: default:
err = fmt.Errorf("invalid payment type: %s", s) err = fmt.Errorf("invalid payout type: %s", s)
} }
return err return err
} }
@@ -83,7 +93,7 @@ func (r *PayoutStatus) UnmarshalText(text []byte) (err error) {
case "failed": case "failed":
*r = StatusFailed *r = StatusFailed
default: default:
err = fmt.Errorf("invalid payment type: %s", s) err = fmt.Errorf("invalid payout type: %s", s)
} }
return err return err
} }
@@ -96,8 +106,10 @@ type SBPBank struct {
type PayoutReq struct { type PayoutReq struct {
PayoutType PayoutType `json:"payout_type"` PayoutType PayoutType `json:"payout_type"`
PayoutToken string `json:"payout_token"`
AccountNumber string `json:"account_number"` AccountNumber string `json:"account_number"`
BankID string `json:"bank_id"` BankID string `json:"bank_id"`
CardNumber string `json:"card_number"`
Amount float32 `json:"amount"` Amount float32 `json:"amount"`
} }

View File

@@ -109,3 +109,17 @@ func (d *dbService) UpdatePayoutByPayoutID(payoutId string, updateModel orm.Payo
p := d.getParams(opts...) p := d.getParams(opts...)
return gorm.G[orm.Payout](d.db).Where("payout_id = ?", payoutId).Updates(p.ctx, updateModel) return gorm.G[orm.Payout](d.db).Where("payout_id = ?", payoutId).Updates(p.ctx, updateModel)
} }
// HealthCheck implements [Service].
func (d *dbService) HealthCheck() error {
if d.db == nil {
return errors.New("database connection is nil")
}
db, err := d.db.DB()
if err != nil {
return err
}
return db.Ping()
}

View File

@@ -31,6 +31,7 @@ type Service interface {
CreatePayout(payoutModel *orm.Payout, opts ...Optional) error CreatePayout(payoutModel *orm.Payout, opts ...Optional) error
UpdatePayoutById(id uint, updateModel orm.Payout, opts ...Optional) (int, error) UpdatePayoutById(id uint, updateModel orm.Payout, opts ...Optional) (int, error)
UpdatePayoutByPayoutID(payoutId string, updateModel orm.Payout, opts ...Optional) (int, error) UpdatePayoutByPayoutID(payoutId string, updateModel orm.Payout, opts ...Optional) (int, error)
HealthCheck() error
} }
// Params represents the module input params // Params represents the module input params

View File

@@ -1,10 +1,3 @@
/*
* Copyright (c) New Cloud Technologies, Ltd., 2013-2026
*
* You can not use the contents of the file in any way without New Cloud Technologies Ltd. written permission.
* To obtain such a permit, you should contact New Cloud Technologies, Ltd. at https://myoffice.ru/contacts/
*/
package config package config
import ( import (

View File

@@ -13,8 +13,9 @@ type YooKassa struct {
ApiBaseKey string ApiBaseKey string
ApiBaseSecret string ApiBaseSecret string
ApiPaymentKey string ApiPayoutKey string
ApiPaymentSecret string ApiPayoutSecret string
WidgetVersion string
CallbackProcessTimeout time.Duration CallbackProcessTimeout time.Duration
} }

View File

@@ -39,7 +39,8 @@ type Metadata map[string]any
type PayoutRequest struct { type PayoutRequest struct {
Amount Amount `json:"amount"` Amount Amount `json:"amount"`
PayoutDestinationData PayoutDestination `json:"payout_destination_data"` PayoutToken string `json:"payout_token,omitempty"`
PayoutDestinationData PayoutDestination `json:"payout_destination_data,omitzero"`
Description string `json:"description"` Description string `json:"description"`
Metadata Metadata `json:"metadata"` Metadata Metadata `json:"metadata"`
Test bool `json:"test"` Test bool `json:"test"`

View File

@@ -26,7 +26,7 @@ type yookassaService struct {
func NewYookassaService(conf config.YooKassa) (Service, error) { func NewYookassaService(conf config.YooKassa) (Service, error) {
client := resty.New() client := resty.New()
client.SetBaseURL(conf.BaseUrl) client.SetBaseURL(conf.BaseUrl)
client.SetBasicAuth(conf.ApiPaymentKey, conf.ApiPaymentSecret) client.SetBasicAuth(conf.ApiPayoutKey, conf.ApiPayoutSecret)
client.SetTimeout(conf.Timeout) client.SetTimeout(conf.Timeout)
if conf.Retry.Enabled { if conf.Retry.Enabled {
@@ -110,10 +110,23 @@ func (y *yookassaService) CreatePayout(req models.PayoutReq, userSession *orm.Us
switch req.PayoutType { switch req.PayoutType {
case models.TypeSBP: case models.TypeSBP:
yReq.PayoutDestinationData.Phone = userSession.Phone yReq.PayoutDestinationData = PayoutDestination{
yReq.PayoutDestinationData.BankID = req.BankID Phone: userSession.Phone,
BankID: req.BankID,
}
case models.TypeYooMoney: case models.TypeYooMoney:
yReq.PayoutDestinationData.AccountNumber = req.AccountNumber yReq.PayoutDestinationData.AccountNumber = req.AccountNumber
case models.TypeCard:
yReq.PayoutDestinationData.Card = Card{
Number: req.CardNumber,
}
case models.TypeCardWidget:
yReq.PayoutToken = req.PayoutToken
yReq.PayoutDestinationData = PayoutDestination{}
default: default:
return nil, errors.New("unsupported payout type") return nil, errors.New("unsupported payout type")
} }

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payouts Page</title>
<script src="https://yookassa.ru/payouts-data/{{ .WidgetVersion }}/widget.js"></script>
<style>
</style>
</head>
<body>
<div id="payout-form"></div>
<script>
// Инициализация виджета. Все параметры обязательные.
const payoutsData = new window.PayoutsData({
type: 'payout',
account_id: '{{ .ApiPayoutKey }}', // Идентификатор шлюза (agentId в личном кабинете)
success_callback: function(data) {
// https://yookassa.ru/developers/payouts/making-payouts/bank-card/using-payout-widget/implementing-widget#reference-output-parameters
if (window.AndroidCallback) {
window.AndroidCallback.onWidgetData(JSON.stringify(data));
} else if (window.webkit && window.webkit.messageHandlers.iosCallback) {
window.webkit.messageHandlers.iosCallback.onWidgetData(JSON.stringify(data));
}
},
error_callback: function(error) {
// https://yookassa.ru/developers/payouts/making-payouts/bank-card/using-payout-widget/implementing-widget#reference-output-parameters-error
if (window.AndroidCallback) {
window.AndroidCallback.onWidgetError(JSON.stringify(error));
} else if (window.webkit && window.webkit.messageHandlers.iosCallback) {
window.webkit.messageHandlers.iosCallback.onWidgetError(JSON.stringify(error));
}
}
});
//Отображение формы в контейнере
payoutsData.render('payout-form')
//Метод возвращает Promise, исполнение которого говорит о полной загрузке формы сбора данных (можно не использовать).
.then(() => {
//Код, который нужно выполнить после отображения формы.
});
</script>
</body>
</html>

View File

@@ -0,0 +1,11 @@
package templates
import (
"embed"
"html/template"
)
//go:embed *.html
var FS embed.FS
var Templates = template.Must(template.ParseFS(FS, "*.html"))

415
openapi.yaml Normal file
View File

@@ -0,0 +1,415 @@
openapi: 3.1.0
info:
title: Payouts Service API
description: API for managing user registrations, authentication, and payouts via YooKassa.
version: 1.0.0
servers:
- url: /
tags:
- name: user
description: User registration and authentication
- name: payout
description: Payout operations
- name: system
description: Health and version endpoints
paths:
/api/v1/user/register:
post:
tags: [user]
summary: Register a new user
operationId: userRegister
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UserRegisterRequest'
responses:
'201':
description: User created successfully
'400':
description: Invalid input (empty fields or password mismatch)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/v1/user/login:
post:
tags: [user]
summary: Authenticate a user and obtain a session token
operationId: userLogin
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UserLoginRequest'
responses:
'200':
description: Login successful
content:
application/json:
schema:
$ref: '#/components/schemas/UserLoginResponse'
'400':
description: Invalid input (empty phone or password)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Invalid credentials
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/v1/payout/sbp/banks:
get:
tags: [payout]
summary: Get list of SBP (Fast Payment System) banks
operationId: getSbpBanks
responses:
'200':
description: List of SBP banks
content:
application/json:
schema:
$ref: '#/components/schemas/SBPBankListResponse'
'400':
description: Bad request (YooKassa API error)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/v1/payout/create:
post:
tags: [payout]
summary: Create a new payout
operationId: createPayout
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PayoutRequest'
responses:
'200':
description: Payout created successfully
content:
application/json:
schema:
$ref: '#/components/schemas/PayoutResponse'
'400':
description: Invalid payout data
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Unauthorized (missing or invalid token)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/v1/payout/callback:
post:
tags: [payout]
summary: Receive payout status callback from YooKassa
description: |
Called by YooKassa to notify of payout status changes.
If IP validation is enabled, the request must originate from an allowed subnet.
Status updates are processed asynchronously.
operationId: payoutCallback
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PayoutResponse'
responses:
'200':
description: Callback received and queued for processing
'400':
description: Invalid JSON payload
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Forbidden (IP address not in allowed subnets)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/payout/widget:
get:
tags: [payout]
summary: Serve the payout widget HTML page
operationId: getPayoutWidget
responses:
'200':
description: Widget HTML page
content:
text/html:
schema:
type: string
'500':
description: Template rendering error
/health:
get:
tags: [system]
summary: Health check
description: Verifies database connectivity.
operationId: healthCheck
responses:
'200':
description: Service is healthy
content:
application/json:
schema:
$ref: '#/components/schemas/HealthResponse'
'503':
description: Service unavailable (database connection failed)
content:
application/json:
schema:
$ref: '#/components/schemas/HealthResponse'
/version:
get:
tags: [system]
summary: Get service version
operationId: getVersion
responses:
'200':
description: Version string (optionally including git commit hash)
content:
text/plain:
schema:
type: string
example: v1.2.3
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: UUID session token obtained from the login endpoint
schemas:
UserRegisterRequest:
type: object
required: [tin, phone, password, password_cfm]
properties:
tin:
type: string
description: Taxpayer Identification Number
example: "1234567890"
phone:
type: string
description: User phone number
example: "+79001234567"
password:
type: string
format: password
description: User password
password_cfm:
type: string
format: password
description: Password confirmation (must match password)
UserLoginRequest:
type: object
required: [phone, password]
properties:
phone:
type: string
description: Registered phone number
example: "+79001234567"
password:
type: string
format: password
UserLoginResponse:
type: object
properties:
token:
type: string
format: uuid
description: Session token (UUID)
example: "550e8400-e29b-41d4-a716-446655440000"
token_ttl:
type: integer
format: int64
description: Token expiration as Unix timestamp
example: 1711958400
SBPBankListResponse:
type: object
properties:
type:
type: string
example: "list"
items:
type: array
items:
$ref: '#/components/schemas/SBPBank'
SBPBank:
type: object
properties:
bank_id:
type: string
description: Bank identifier
example: "100000000111"
name:
type: string
description: Human-readable bank name
example: "Sberbank"
bic:
type: string
description: Bank Identification Code
example: "044525225"
PayoutType:
type: string
enum: [spb, yoo_money, bank_card, widget]
description: |
Payout method:
- `spb` — Fast Payment System (SBP)
- `yoo_money` — YooMoney wallet
- `bank_card` — bank card
- `widget` — card via widget
PayoutRequest:
type: object
required: [payout_type, amount]
properties:
payout_type:
$ref: '#/components/schemas/PayoutType'
payout_token:
type: string
description: Payment token (used for widget/card payouts)
example: "pt_xxxxxxxxxxxxxxxxxxxx"
account_number:
type: string
description: Account/phone number for SBP or YooMoney payouts
example: "+79001234567"
bank_id:
type: string
description: Bank identifier (required for SBP payouts)
example: "100000000111"
card_number:
type: string
description: Card number (for bank_card payout type)
example: "4111111111111111"
amount:
type: number
format: float
description: Payout amount in RUB
example: 1000.00
PayoutStatus:
type: string
enum: [created, pending, succeeded, canceled, failed]
Amount:
type: object
properties:
value:
type: string
description: Amount as a decimal string
example: "1000.00"
currency:
type: string
description: ISO 4217 currency code
example: "RUB"
PayoutDestination:
type: object
description: Payout destination details (structure depends on payout type)
additionalProperties: true
PayoutResponse:
type: object
properties:
id:
type: string
description: YooKassa payout ID
example: "po_1da5c87d-0984-50e8-a7f3-8de646dd9ec9"
status:
$ref: '#/components/schemas/PayoutStatus'
amount:
$ref: '#/components/schemas/Amount'
payout_destination:
$ref: '#/components/schemas/PayoutDestination'
description:
type: string
example: "Payout for order #42"
created_at:
type: string
format: date-time
example: "2024-04-02T12:00:00Z"
succeeded_at:
type: string
format: date-time
example: "2024-04-02T12:01:00Z"
metadata:
type: object
additionalProperties: true
description: Arbitrary key-value metadata
test:
type: boolean
description: Whether this is a test payout
example: false
HealthResponse:
type: object
properties:
OK:
type: boolean
description: Whether the service is healthy
Error:
type: string
description: Error message (only present when OK is false)
ErrorResponse:
type: object
properties:
status:
type: integer
description: HTTP status code
example: 400
message:
type: string
description: Human-readable error message
example: "Bad Request"