diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6278a52 --- /dev/null +++ b/Dockerfile @@ -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" ] diff --git a/README.md b/README.md index ed6e067..abf502e 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,8 @@ | YooKassa.Test | Enable test mode | false | | YooKassa.ApiBaseKey | YooKassa base API key | (empty) | | YooKassa.ApiBaseSecret | YooKassa base API secret | (empty) | -| YooKassa.ApiPaymentKey | YooKassa payment API key | (empty) | -| YooKassa.ApiPaymentSecret | YooKassa payment API secret | (empty) | +| YooKassa.ApiPayoutKey | YooKassa payout API key | (empty) | +| YooKassa.ApiPayoutSecret | YooKassa payout API secret | (empty) | | YooKassa.BaseUrl | YooKassa API base URL | https://api.yookassa.ru/v3 | | YooKassa.Timeout | Request timeout | 2s | | YooKassa.CheckAllowedCallbackAddress | Check callback IP addresses | true | diff --git a/config/payouts.properties b/config/payouts.properties index 7786e64..d7d7cb7 100644 --- a/config/payouts.properties +++ b/config/payouts.properties @@ -55,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 YooKassa.ApiBaseKey = YooKassa.ApiBaseSecret = -# Payments API key/secret -YooKassa.ApiPaymentKey = -YooKassa.ApiPaymentSecret = +# Payouts API key/secret +YooKassa.ApiPayoutKey = +YooKassa.ApiPayoutSecret = # Timeout to process yookassa callback YooKassa.CallbackProcessTimeout = 1s +# Widget version +YooKassa.WidgetVersion = 3.1.0 diff --git a/helm/payouts/README.md b/helm/payouts/README.md index 652446d..4d91541 100644 --- a/helm/payouts/README.md +++ b/helm/payouts/README.md @@ -156,8 +156,8 @@ variables. Variable names are the uppercased property key with `.` replaced by ` | `DATABASE_CONNECTION` | `Database.Connection` | | `YOOKASSA_APIBASEKEY` | `YooKassa.ApiBaseKey` | | `YOOKASSA_APIBASESECRET` | `YooKassa.ApiBaseSecret` | -| `YOOKASSA_APIPAYMENTKEY` | `YooKassa.ApiPaymentKey` | -| `YOOKASSA_APIPAYMENTSECRET` | `YooKassa.ApiPaymentSecret` | +| `YOOKASSA_APIPAYOUTKEY` | `YooKassa.ApiPayoutKey` | +| `YOOKASSA_APIPAYOUTSECRET` | `YooKassa.ApiPayoutSecret` | Provide secrets at install/upgrade time: @@ -166,8 +166,8 @@ 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="" \ --set secrets.YOOKASSA_APIBASESECRET="" \ - --set secrets.YOOKASSA_APIPAYMENTKEY="" \ - --set secrets.YOOKASSA_APIPAYMENTSECRET="" + --set secrets.YOOKASSA_APIPAYOUTKEY="" \ + --set secrets.YOOKASSA_APIPAYOUTSECRET="" ``` Or keep them in a separate values file that is **not committed to version control**: @@ -180,11 +180,11 @@ Example `secrets.values.yaml`: ```yaml secrets: - DATABASE_CONNECTION: "host=127.0.0.1 user=app password=s3cr3t dbname=payouts port=5432 sslmode=disable" + DATABASE_CONNECTION: "host=127.0.0.1 user=payouts password=password dbname=payouts port=5432 sslmode=disable" YOOKASSA_APIBASEKEY: "" YOOKASSA_APIBASESECRET: "" - YOOKASSA_APIPAYMENTKEY: "" - YOOKASSA_APIPAYMENTSECRET: "" + YOOKASSA_APIPAYOUTKEY: "" + YOOKASSA_APIPAYOUTSECRET: "" ``` ### Ingress example diff --git a/helm/payouts/templates/NOTES.txt b/helm/payouts/templates/NOTES.txt index 009fe4f..2447299 100644 --- a/helm/payouts/templates/NOTES.txt +++ b/helm/payouts/templates/NOTES.txt @@ -31,15 +31,15 @@ 3. Secret environment variables are injected from Secret {{ include "payouts.fullname" . }}: {{- end }} DATABASE_CONNECTION, YOOKASSA_APIBASEKEY, YOOKASSA_APIBASESECRET, - YOOKASSA_APIPAYMENTKEY, YOOKASSA_APIPAYMENTSECRET + 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="" \ --set secrets.YOOKASSA_APIBASESECRET="" \ - --set secrets.YOOKASSA_APIPAYMENTKEY="" \ - --set secrets.YOOKASSA_APIPAYMENTSECRET="" + --set secrets.YOOKASSA_APIPAYOUTKEY="" \ + --set secrets.YOOKASSA_APIPAYOUTSECRET="" Or use a separate values file that is not committed to version control: helm upgrade {{ .Release.Name }} ./helm -f secrets.values.yaml diff --git a/helm/payouts/values.yaml b/helm/payouts/values.yaml index 76469c6..d0fb9e3 100644 --- a/helm/payouts/values.yaml +++ b/helm/payouts/values.yaml @@ -101,5 +101,5 @@ secrets: DATABASE_CONNECTION: "" YOOKASSA_APIBASEKEY: "" YOOKASSA_APIBASESECRET: "" - YOOKASSA_APIPAYMENTKEY: "" - YOOKASSA_APIPAYMENTSECRET: "" + YOOKASSA_APIPAYOUTKEY: "" + YOOKASSA_APIPAYOUTSECRET: "" diff --git a/internal/api/module.go b/internal/api/module.go index 0d6dcf1..d8482ab 100644 --- a/internal/api/module.go +++ b/internal/api/module.go @@ -15,6 +15,7 @@ import ( "payouts/internal/api/payout" "payouts/internal/api/user" "payouts/internal/api/version" + "payouts/internal/api/widget" appConfig "payouts/internal/config" "payouts/internal/service/monitoring" ) @@ -25,6 +26,7 @@ var Module = fx.Options( health.Module, payout.Module, version.Module, + widget.Module, monitoring.Module, fx.Invoke(RegisterRoutes), @@ -41,8 +43,9 @@ type Params struct { PayoutHandler payout.Handler UserHandler user.Handler - Version version.Handler HealthHandler health.Handler + Version version.Handler + Widget widget.Handler Metrics monitoring.Metrics } @@ -77,6 +80,9 @@ func RegisterRoutes(p Params, lc fx.Lifecycle) { payoutRouter.HandleFunc(payout.CreateRoute, p.PayoutHandler.PayoutCreate).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 apiRouter.Use(p.Metrics.GetMiddleware()) diff --git a/internal/api/payout/payout_handler.go b/internal/api/payout/payout_handler.go index 86166de..fdb0248 100644 --- a/internal/api/payout/payout_handler.go +++ b/internal/api/payout/payout_handler.go @@ -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) { 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) { // todo: check also the X-real-ip and/or X-Forwarded-For if p.yookassaConf.CheckAllowedCallbackAddress && !p.checkAllowedIpCallback(r.RemoteAddr) { diff --git a/internal/api/widget/module.go b/internal/api/widget/module.go new file mode 100644 index 0000000..c3b27aa --- /dev/null +++ b/internal/api/widget/module.go @@ -0,0 +1,9 @@ +package widget + +import ( + "go.uber.org/fx" +) + +var Module = fx.Options( + fx.Provide(NewWidgetHandler), +) \ No newline at end of file diff --git a/internal/api/widget/widget_handler.go b/internal/api/widget/widget_handler.go new file mode 100644 index 0000000..6908ebd --- /dev/null +++ b/internal/api/widget/widget_handler.go @@ -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 + } +} diff --git a/internal/models/payout.go b/internal/models/payout.go index 2873aa9..5758d8f 100644 --- a/internal/models/payout.go +++ b/internal/models/payout.go @@ -10,6 +10,8 @@ type PayoutType int64 const ( TypeSBP PayoutType = iota TypeYooMoney + TypeCard + TypeCardWidget ) func (r PayoutType) String() string { @@ -18,6 +20,10 @@ func (r PayoutType) String() string { return "spb" case TypeYooMoney: return "yoo_money" + case TypeCard: + return "bank_card" + case TypeCardWidget: + return "widget" } return "unknown" } @@ -33,8 +39,12 @@ func (r *PayoutType) UnmarshalText(text []byte) (err error) { *r = TypeSBP case "yoo_money": *r = TypeYooMoney + case "bank_card": + *r = TypeCard + case "widget": + *r = TypeCardWidget default: - err = fmt.Errorf("invalid payment type: %s", s) + err = fmt.Errorf("invalid payout type: %s", s) } return err } @@ -83,7 +93,7 @@ func (r *PayoutStatus) UnmarshalText(text []byte) (err error) { case "failed": *r = StatusFailed default: - err = fmt.Errorf("invalid payment type: %s", s) + err = fmt.Errorf("invalid payout type: %s", s) } return err } @@ -96,8 +106,10 @@ type SBPBank struct { type PayoutReq struct { PayoutType PayoutType `json:"payout_type"` + PayoutToken string `json:"payout_token"` AccountNumber string `json:"account_number"` BankID string `json:"bank_id"` + CardNumber string `json:"card_number"` Amount float32 `json:"amount"` } diff --git a/internal/service/yookassa/config/yookassa.go b/internal/service/yookassa/config/yookassa.go index 1682ff1..26d0691 100644 --- a/internal/service/yookassa/config/yookassa.go +++ b/internal/service/yookassa/config/yookassa.go @@ -13,8 +13,9 @@ type YooKassa struct { ApiBaseKey string ApiBaseSecret string - ApiPaymentKey string - ApiPaymentSecret string + ApiPayoutKey string + ApiPayoutSecret string + WidgetVersion string CallbackProcessTimeout time.Duration } diff --git a/internal/service/yookassa/yookassa_api.go b/internal/service/yookassa/yookassa_api.go index 8c9ff68..8f5a429 100644 --- a/internal/service/yookassa/yookassa_api.go +++ b/internal/service/yookassa/yookassa_api.go @@ -39,7 +39,8 @@ type Metadata map[string]any type PayoutRequest struct { 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"` Metadata Metadata `json:"metadata"` Test bool `json:"test"` diff --git a/internal/service/yookassa/yookassa_service.go b/internal/service/yookassa/yookassa_service.go index e9abeaf..b161601 100644 --- a/internal/service/yookassa/yookassa_service.go +++ b/internal/service/yookassa/yookassa_service.go @@ -26,7 +26,7 @@ type yookassaService struct { func NewYookassaService(conf config.YooKassa) (Service, error) { client := resty.New() client.SetBaseURL(conf.BaseUrl) - client.SetBasicAuth(conf.ApiPaymentKey, conf.ApiPaymentSecret) + client.SetBasicAuth(conf.ApiPayoutKey, conf.ApiPayoutSecret) client.SetTimeout(conf.Timeout) if conf.Retry.Enabled { @@ -110,10 +110,23 @@ func (y *yookassaService) CreatePayout(req models.PayoutReq, userSession *orm.Us switch req.PayoutType { case models.TypeSBP: - yReq.PayoutDestinationData.Phone = userSession.Phone - yReq.PayoutDestinationData.BankID = req.BankID + yReq.PayoutDestinationData = PayoutDestination{ + Phone: userSession.Phone, + BankID: req.BankID, + } + case models.TypeYooMoney: 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: return nil, errors.New("unsupported payout type") } diff --git a/internal/templates/payouts-widget.html b/internal/templates/payouts-widget.html new file mode 100644 index 0000000..6c68ba3 --- /dev/null +++ b/internal/templates/payouts-widget.html @@ -0,0 +1,45 @@ + + + + + + Payouts Page + + + + +
+ + + + \ No newline at end of file diff --git a/internal/templates/templates.go b/internal/templates/templates.go new file mode 100644 index 0000000..778b324 --- /dev/null +++ b/internal/templates/templates.go @@ -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")) \ No newline at end of file