mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
468 lines
14 KiB
Go
468 lines
14 KiB
Go
package powens
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/types/currency"
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/types/pointer"
|
|
"github.com/hanzo-fi/payments/ce/plugins/powens/client"
|
|
"github.com/hanzo-fi/payments/pkg/domain/models"
|
|
)
|
|
|
|
const (
|
|
webhookSecretMetadataKey = "secret"
|
|
|
|
webhookAccountSyncedTransactionsLimit = 100
|
|
)
|
|
|
|
type supportedWebhook struct {
|
|
urlPath string
|
|
trimFunction func(context.Context, models.TrimWebhookRequest) (models.TrimWebhookResponse, error)
|
|
handleFunction func(context.Context, models.TranslateWebhookRequest) ([]models.WebhookResponse, error)
|
|
}
|
|
|
|
func (p *Plugin) initWebhookConfig() {
|
|
p.supportedWebhooks = map[client.WebhookEventType]supportedWebhook{
|
|
client.WebhookEventTypeUserDeleted: {
|
|
urlPath: "/user-deleted",
|
|
handleFunction: p.handleUserDeleted,
|
|
},
|
|
client.WebhookEventTypeConnectionSynced: {
|
|
urlPath: "/connection-synced",
|
|
trimFunction: p.trimConnectionSynced,
|
|
handleFunction: p.handleConnectionSynced,
|
|
},
|
|
client.WebhookEventTypeConnectionDeleted: {
|
|
urlPath: "/connection-deleted",
|
|
handleFunction: p.handleConnectionDeleted,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (p *Plugin) createWebhooks(ctx context.Context, req models.CreateWebhooksRequest) (models.CreateWebhooksResponse, error) {
|
|
secretKey, err := p.client.CreateWebhookAuth(ctx, p.name)
|
|
if err != nil {
|
|
return models.CreateWebhooksResponse{}, err
|
|
}
|
|
|
|
configs := make([]models.PSPWebhookConfig, 0, len(p.supportedWebhooks))
|
|
for eventType, w := range p.supportedWebhooks {
|
|
configs = append(configs, models.PSPWebhookConfig{
|
|
Name: string(eventType),
|
|
URLPath: w.urlPath,
|
|
Metadata: map[string]string{
|
|
webhookSecretMetadataKey: secretKey,
|
|
},
|
|
})
|
|
}
|
|
|
|
return models.CreateWebhooksResponse{
|
|
Configs: configs,
|
|
}, nil
|
|
}
|
|
|
|
func (p *Plugin) deleteWebhooks(ctx context.Context, req models.UninstallRequest) error {
|
|
auths, err := p.client.ListWebhookAuths(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, auth := range auths {
|
|
if auth.Name == p.name {
|
|
if err := p.client.DeleteWebhookAuth(ctx, auth.ID); err != nil {
|
|
return err
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *Plugin) verifyWebhook(_ context.Context, req models.VerifyWebhookRequest) (models.VerifyWebhookResponse, error) {
|
|
signatureDate, ok := req.Webhook.Headers["Bi-Signature-Date"]
|
|
if !ok || len(signatureDate) != 1 {
|
|
return models.VerifyWebhookResponse{}, fmt.Errorf("missing powens signature date header: %w", models.ErrWebhookVerification)
|
|
}
|
|
|
|
signature, ok := req.Webhook.Headers["Bi-Signature"]
|
|
if !ok || len(signature) != 1 {
|
|
return models.VerifyWebhookResponse{}, fmt.Errorf("missing powens signature header: %w", models.ErrWebhookVerification)
|
|
}
|
|
|
|
u, err := url.Parse(req.Config.FullURL)
|
|
if err != nil {
|
|
return models.VerifyWebhookResponse{}, fmt.Errorf("invalid powens url: %w", models.ErrWebhookVerification)
|
|
}
|
|
|
|
secretKey, ok := req.Config.Metadata[webhookSecretMetadataKey]
|
|
if !ok {
|
|
return models.VerifyWebhookResponse{}, fmt.Errorf("missing powens secret key: %w", models.ErrWebhookVerification)
|
|
}
|
|
|
|
messageToSign := fmt.Sprintf("POST.%s.%s.%s", u.Path, signatureDate[0], string(req.Webhook.Body))
|
|
mac := hmac.New(sha256.New, []byte(secretKey))
|
|
mac.Write([]byte(messageToSign))
|
|
expectedSignature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
|
|
|
sigBytes, err := base64.StdEncoding.DecodeString(signature[0])
|
|
if err != nil {
|
|
return models.VerifyWebhookResponse{}, fmt.Errorf("invalid signature encoding: %w", models.ErrWebhookVerification)
|
|
}
|
|
|
|
expBytes, err := base64.StdEncoding.DecodeString(expectedSignature)
|
|
if err != nil {
|
|
return models.VerifyWebhookResponse{}, fmt.Errorf("invalid signature encoding: %w", models.ErrWebhookVerification)
|
|
}
|
|
|
|
if !hmac.Equal(expBytes, sigBytes) {
|
|
return models.VerifyWebhookResponse{}, fmt.Errorf("invalid powens signature: %w", models.ErrWebhookVerification)
|
|
}
|
|
|
|
return models.VerifyWebhookResponse{}, nil
|
|
}
|
|
|
|
func (p *Plugin) handleUserDeleted(ctx context.Context, req models.TranslateWebhookRequest) ([]models.WebhookResponse, error) {
|
|
var webhook client.UserDeletedWebhook
|
|
if err := json.Unmarshal(req.Webhook.Body, &webhook); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return []models.WebhookResponse{
|
|
{
|
|
UserDisconnected: &models.PSPUserDisconnected{
|
|
PSPUserID: strconv.Itoa(webhook.UserID),
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (p *Plugin) trimConnectionSynced(_ context.Context, req models.TrimWebhookRequest) (models.TrimWebhookResponse, error) {
|
|
if len(req.Webhook.Body) == 0 {
|
|
return models.TrimWebhookResponse{}, fmt.Errorf("missing powens accounts synced webhook body: %w", models.ErrValidation)
|
|
}
|
|
|
|
var webhook client.ConnectionSyncedWebhook
|
|
if err := json.Unmarshal(req.Webhook.Body, &webhook); err != nil {
|
|
return models.TrimWebhookResponse{}, err
|
|
}
|
|
|
|
webhooks := make([]models.PSPWebhook, 0)
|
|
for _, account := range webhook.Connection.Accounts {
|
|
acc := account
|
|
index := 0
|
|
for {
|
|
connectionSyncedWebhook := client.ConnectionSyncedWebhook{
|
|
User: webhook.User,
|
|
Connection: client.ConnectionSyncedConnection{
|
|
ID: webhook.Connection.ID,
|
|
State: webhook.Connection.State,
|
|
ErrorMessage: webhook.Connection.ErrorMessage,
|
|
LastUpdate: webhook.Connection.LastUpdate,
|
|
Active: webhook.Connection.Active,
|
|
},
|
|
}
|
|
|
|
// Add balances here
|
|
ba := client.BankAccount{
|
|
ID: acc.ID,
|
|
ConnectionID: acc.ConnectionID,
|
|
UserID: acc.UserID,
|
|
OriginalName: acc.OriginalName,
|
|
LastUpdate: acc.LastUpdate,
|
|
Currency: acc.Currency,
|
|
Balance: acc.Balance,
|
|
Transactions: make([]client.Transaction, 0, webhookAccountSyncedTransactionsLimit),
|
|
}
|
|
|
|
limit := index + webhookAccountSyncedTransactionsLimit
|
|
for ; index < len(acc.Transactions) && index < limit; index++ {
|
|
ba.Transactions = append(ba.Transactions, acc.Transactions[index])
|
|
}
|
|
|
|
connectionSyncedWebhook.Connection.Accounts = append(connectionSyncedWebhook.Connection.Accounts, ba)
|
|
|
|
body, err := json.Marshal(connectionSyncedWebhook)
|
|
if err != nil {
|
|
return models.TrimWebhookResponse{}, err
|
|
}
|
|
|
|
w := models.PSPWebhook{
|
|
BasicAuth: req.Webhook.BasicAuth,
|
|
QueryValues: req.Webhook.QueryValues,
|
|
Headers: req.Webhook.Headers,
|
|
Body: body,
|
|
}
|
|
|
|
webhooks = append(webhooks, w)
|
|
|
|
if index >= len(acc.Transactions) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return models.TrimWebhookResponse{
|
|
Webhooks: webhooks,
|
|
}, nil
|
|
}
|
|
|
|
func (p *Plugin) handleConnectionSynced(ctx context.Context, req models.TranslateWebhookRequest) ([]models.WebhookResponse, error) {
|
|
var webhook client.ConnectionSyncedWebhook
|
|
if err := json.Unmarshal(req.Webhook.Body, &webhook); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch webhook.Connection.State {
|
|
case "null", "":
|
|
// There is only one account in the webhook since we trimmed it before
|
|
account := webhook.Connection.Accounts[0]
|
|
|
|
pspAccount, err := translateBankAccountToPSPAccount(account)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
accountsResponse := models.WebhookResponse{
|
|
OpenBankingAccount: &models.PSPOpenBankingAccount{
|
|
PSPAccount: pspAccount,
|
|
OpenBankingUserID: pointer.For(strconv.Itoa(webhook.User.ID)),
|
|
OpenBankingConnectionID: pointer.For(strconv.Itoa(webhook.Connection.ID)),
|
|
},
|
|
}
|
|
|
|
transactionResponses := make([]models.WebhookResponse, 0, len(account.Transactions))
|
|
for _, transaction := range account.Transactions {
|
|
payment, err := translateTransactionToPSPPayment(transaction, pspAccount.Reference, account.Currency.ID, account.Currency.Precision)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
obPayment := models.PSPOpenBankingPayment{
|
|
PSPPayment: payment,
|
|
OpenBankingUserID: pointer.For(strconv.Itoa(account.UserID)),
|
|
OpenBankingConnectionID: pointer.For(strconv.Itoa(account.ConnectionID)),
|
|
}
|
|
|
|
transactionResponses = append(transactionResponses, models.WebhookResponse{
|
|
OpenBankingPayment: &obPayment,
|
|
})
|
|
}
|
|
|
|
at := time.Now().UTC()
|
|
if !account.LastUpdate.IsZero() {
|
|
at = account.LastUpdate
|
|
}
|
|
|
|
var balanceResponse *models.WebhookResponse
|
|
if account.Balance.String() != "" {
|
|
amount, err := currency.GetAmountWithPrecisionFromString(account.Balance.String(), account.Currency.Precision)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
balanceResponse = &models.WebhookResponse{
|
|
Balance: &models.PSPBalance{
|
|
AccountReference: pspAccount.Reference,
|
|
CreatedAt: at,
|
|
Asset: *pspAccount.DefaultAsset,
|
|
Amount: amount,
|
|
},
|
|
}
|
|
}
|
|
|
|
// We have to put first the user connection reconnected webhook in order
|
|
// to be sure that the connection is created before the payments and
|
|
// accounts are ingested.
|
|
res := []models.WebhookResponse{
|
|
{
|
|
UserConnectionReconnected: &models.PSPUserConnectionReconnected{
|
|
PSPUserID: strconv.Itoa(webhook.User.ID),
|
|
ConnectionID: strconv.Itoa(webhook.Connection.ID),
|
|
At: at,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Then create the accounts
|
|
res = append(res, accountsResponse)
|
|
// And finally the transactions related to the connection and the account
|
|
res = append(res, transactionResponses...)
|
|
// Finally, push the latest balance for the account
|
|
if balanceResponse != nil {
|
|
res = append(res, *balanceResponse)
|
|
}
|
|
|
|
return res, nil
|
|
|
|
case "SCARequired", "webauthRequired", "additionalInformationNeeded",
|
|
"decoupled", "actionNeeded", "wrongpass", "passwordExpired":
|
|
var reason *string
|
|
if webhook.Connection.ErrorMessage != "" {
|
|
reason = pointer.For(webhook.Connection.ErrorMessage)
|
|
} else {
|
|
reason = pointer.For("SCA error")
|
|
}
|
|
|
|
return []models.WebhookResponse{
|
|
{
|
|
UserConnectionDisconnected: &models.PSPUserConnectionDisconnected{
|
|
PSPUserID: strconv.Itoa(webhook.User.ID),
|
|
ConnectionID: strconv.Itoa(webhook.Connection.ID),
|
|
At: time.Now().UTC(),
|
|
ErrorType: models.ConnectionDisconnectedErrorTypeUserActionNeeded,
|
|
Reason: reason,
|
|
},
|
|
},
|
|
}, nil
|
|
case "validating":
|
|
return []models.WebhookResponse{
|
|
{
|
|
UserConnectionDisconnected: &models.PSPUserConnectionDisconnected{
|
|
PSPUserID: strconv.Itoa(webhook.User.ID),
|
|
ConnectionID: strconv.Itoa(webhook.Connection.ID),
|
|
At: time.Now().UTC(),
|
|
ErrorType: models.ConnectionDisconnectedErrorTypeUserActionNeeded,
|
|
Reason: pointer.For("temporary error: validation in progress"),
|
|
},
|
|
},
|
|
}, nil
|
|
case "rateLimiting":
|
|
return []models.WebhookResponse{
|
|
{
|
|
UserConnectionDisconnected: &models.PSPUserConnectionDisconnected{
|
|
PSPUserID: strconv.Itoa(webhook.User.ID),
|
|
ConnectionID: strconv.Itoa(webhook.Connection.ID),
|
|
At: time.Now().UTC(),
|
|
ErrorType: models.ConnectionDisconnectedErrorTypeTemporaryError,
|
|
Reason: pointer.For("temporary error: rate limiting"),
|
|
},
|
|
},
|
|
}, nil
|
|
case "websiteUnavailable":
|
|
return []models.WebhookResponse{
|
|
{
|
|
UserConnectionDisconnected: &models.PSPUserConnectionDisconnected{
|
|
PSPUserID: strconv.Itoa(webhook.User.ID),
|
|
ConnectionID: strconv.Itoa(webhook.Connection.ID),
|
|
ErrorType: models.ConnectionDisconnectedErrorTypeTemporaryError,
|
|
At: time.Now().UTC(),
|
|
Reason: pointer.For("non recoverable error: website unavailable"),
|
|
},
|
|
},
|
|
}, nil
|
|
case "bug":
|
|
return []models.WebhookResponse{
|
|
{
|
|
UserConnectionDisconnected: &models.PSPUserConnectionDisconnected{
|
|
PSPUserID: strconv.Itoa(webhook.User.ID),
|
|
ConnectionID: strconv.Itoa(webhook.Connection.ID),
|
|
ErrorType: models.ConnectionDisconnectedErrorTypeNonRecoverable,
|
|
At: time.Now().UTC(),
|
|
Reason: pointer.For("powens internal error: please contact support"),
|
|
},
|
|
},
|
|
}, nil
|
|
default:
|
|
return []models.WebhookResponse{
|
|
{
|
|
UserConnectionDisconnected: &models.PSPUserConnectionDisconnected{
|
|
PSPUserID: strconv.Itoa(webhook.User.ID),
|
|
ConnectionID: strconv.Itoa(webhook.Connection.ID),
|
|
ErrorType: models.ConnectionDisconnectedErrorTypeNonRecoverable,
|
|
At: time.Now().UTC(),
|
|
Reason: pointer.For("other errors: please contact support"),
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
func (p *Plugin) handleConnectionDeleted(ctx context.Context, req models.TranslateWebhookRequest) ([]models.WebhookResponse, error) {
|
|
var webhook client.ConnectionDeletedWebhook
|
|
if err := json.Unmarshal(req.Webhook.Body, &webhook); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return []models.WebhookResponse{
|
|
{
|
|
UserConnectionDisconnected: &models.PSPUserConnectionDisconnected{
|
|
ConnectionID: strconv.Itoa(webhook.ConnectionID),
|
|
ErrorType: models.ConnectionDisconnectedErrorTypeUserActionNeeded,
|
|
At: time.Now().UTC(),
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func translateBankAccountToPSPAccount(account client.BankAccount) (models.PSPAccount, error) {
|
|
acc := account
|
|
// We don't need the transactions in the raw payload of the account
|
|
acc.Transactions = nil
|
|
raw, err := json.Marshal(acc)
|
|
if err != nil {
|
|
return models.PSPAccount{}, err
|
|
}
|
|
|
|
res := models.PSPAccount{
|
|
Reference: strconv.Itoa(account.ID),
|
|
CreatedAt: time.Now().UTC(),
|
|
Name: &account.OriginalName,
|
|
DefaultAsset: pointer.For(currency.FormatAssetWithPrecision(account.Currency.ID, account.Currency.Precision)),
|
|
Raw: raw,
|
|
}
|
|
|
|
if account.Error != "" {
|
|
res.Metadata = map[string]string{
|
|
"error": account.Error,
|
|
}
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
func translateTransactionToPSPPayment(transaction client.Transaction, accountReference string, curr string, precision int) (models.PSPPayment, error) {
|
|
|
|
amount, err := currency.GetAmountWithPrecisionFromString(transaction.Value.String(), precision)
|
|
if err != nil {
|
|
return models.PSPPayment{}, err
|
|
}
|
|
|
|
paymentType := models.PAYMENT_TYPE_PAYIN
|
|
if amount.Sign() == -1 {
|
|
paymentType = models.PAYMENT_TYPE_PAYOUT
|
|
}
|
|
|
|
raw, err := json.Marshal(transaction)
|
|
if err != nil {
|
|
return models.PSPPayment{}, err
|
|
}
|
|
|
|
p := models.PSPPayment{
|
|
Reference: strconv.Itoa(transaction.ID),
|
|
CreatedAt: transaction.Date,
|
|
Type: paymentType,
|
|
Amount: amount,
|
|
Asset: currency.FormatAssetWithPrecision(curr, precision),
|
|
Scheme: models.PAYMENT_SCHEME_OTHER,
|
|
Status: models.PAYMENT_STATUS_SUCCEEDED,
|
|
Raw: raw,
|
|
}
|
|
|
|
switch paymentType {
|
|
case models.PAYMENT_TYPE_PAYIN:
|
|
p.DestinationAccountReference = &accountReference
|
|
case models.PAYMENT_TYPE_PAYOUT:
|
|
p.SourceAccountReference = &accountReference
|
|
}
|
|
|
|
return p, nil
|
|
}
|