mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
184 lines
6.4 KiB
Go
184 lines
6.4 KiB
Go
package qonto
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/types/pointer"
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/types/currency"
|
|
"github.com/hanzo-fi/payments/ce/plugins/qonto/client"
|
|
errorsutils "github.com/hanzo-fi/payments/pkg/domain/errors"
|
|
"time"
|
|
|
|
"github.com/hanzo-fi/payments/pkg/domain/models"
|
|
"github.com/hanzo-fi/payments/pkg/domain/pagination"
|
|
)
|
|
|
|
type externalAccountsState struct {
|
|
LastUpdatedAt time.Time `json:"lastUpdatedAt"`
|
|
Page int `json:"page"`
|
|
LastProcessedId string `json:"lastProcessedId"`
|
|
}
|
|
|
|
/*
|
|
*
|
|
This is a classic implementation, using primarily lastUpdatedAt for pagination. However this has an edge case, if multiple
|
|
external accounts have the same updatedAt -- if the state.lastUpdatedAt doesn't change (as in all external accounts in a
|
|
page were updated at the same time), we have to use Qonto's pagination in addition of the lastUpdatedFrom parameter.
|
|
*/
|
|
func (p *Plugin) fetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) {
|
|
if req.PageSize == 0 {
|
|
return models.FetchNextExternalAccountsResponse{}, models.ErrMissingPageSize
|
|
}
|
|
if req.PageSize > client.QontoMaxPageSize {
|
|
return models.FetchNextExternalAccountsResponse{}, models.ErrExceededMaxPageSize
|
|
}
|
|
var oldState externalAccountsState
|
|
if req.State != nil {
|
|
if err := json.Unmarshal(req.State, &oldState); err != nil {
|
|
err := errorsutils.NewWrappedError(
|
|
fmt.Errorf("failed to unmarshall state"),
|
|
err,
|
|
)
|
|
return models.FetchNextExternalAccountsResponse{}, err
|
|
}
|
|
}
|
|
if oldState.Page == 0 {
|
|
oldState.Page = 1
|
|
}
|
|
newState := externalAccountsState{
|
|
LastUpdatedAt: oldState.LastUpdatedAt,
|
|
Page: oldState.Page,
|
|
LastProcessedId: oldState.LastProcessedId,
|
|
}
|
|
|
|
hasMore := false
|
|
accounts := make([]models.PSPAccount, 0, req.PageSize)
|
|
|
|
beneficiaries, err := p.client.GetBeneficiaries(ctx, oldState.LastUpdatedAt, oldState.Page, req.PageSize)
|
|
if err != nil {
|
|
return models.FetchNextExternalAccountsResponse{}, err
|
|
}
|
|
|
|
accounts, err = p.beneficiaryToPSPAccounts(oldState.LastUpdatedAt, oldState.LastProcessedId, accounts, beneficiaries)
|
|
if err != nil {
|
|
return models.FetchNextExternalAccountsResponse{}, err
|
|
}
|
|
|
|
_, hasMore = pagination.ShouldFetchMore(accounts, beneficiaries, req.PageSize)
|
|
|
|
if len(accounts) > 0 {
|
|
var err error
|
|
newState.LastUpdatedAt, err = time.ParseInLocation(client.QontoTimeformat, accounts[len(accounts)-1].Metadata["updated_at"], time.UTC)
|
|
if err != nil {
|
|
return models.FetchNextExternalAccountsResponse{}, err
|
|
}
|
|
newState.LastProcessedId = accounts[len(accounts)-1].Reference
|
|
}
|
|
if newState.LastUpdatedAt.Equal(oldState.LastUpdatedAt) {
|
|
newState.Page++
|
|
} else {
|
|
newState.Page = 1
|
|
}
|
|
|
|
payload, err := json.Marshal(newState)
|
|
if err != nil {
|
|
return models.FetchNextExternalAccountsResponse{}, err
|
|
}
|
|
|
|
return models.FetchNextExternalAccountsResponse{
|
|
ExternalAccounts: accounts,
|
|
NewState: payload,
|
|
HasMore: hasMore,
|
|
}, nil
|
|
}
|
|
|
|
func (p *Plugin) beneficiaryToPSPAccounts(
|
|
oldUpdatedAt time.Time,
|
|
lastProcessedId string,
|
|
accounts []models.PSPAccount,
|
|
pagedBeneficiaries []client.Beneficiary,
|
|
) ([]models.PSPAccount, error) {
|
|
for _, beneficiary := range pagedBeneficiaries {
|
|
updatedAt, err := time.ParseInLocation(client.QontoTimeformat, beneficiary.UpdatedAt, time.UTC)
|
|
if err != nil {
|
|
err := errorsutils.NewWrappedError(
|
|
fmt.Errorf("invalid time format for updatedAt beneficiary"),
|
|
err,
|
|
)
|
|
return accounts, err
|
|
}
|
|
createdAt, err := time.ParseInLocation(client.QontoTimeformat, beneficiary.CreatedAt, time.UTC)
|
|
if err != nil {
|
|
err := errorsutils.NewWrappedError(
|
|
fmt.Errorf("invalid time format for createdAt beneficiary"),
|
|
err,
|
|
)
|
|
return accounts, err
|
|
}
|
|
raw, err := json.Marshal(beneficiary)
|
|
if err != nil {
|
|
return accounts, err
|
|
}
|
|
accountReference, err := generateAccountReference(
|
|
beneficiary.BankAccount.AccountNumber,
|
|
beneficiary.BankAccount.Iban,
|
|
beneficiary.BankAccount.Bic,
|
|
beneficiary.BankAccount.SwiftSortCode,
|
|
beneficiary.BankAccount.RoutingNumber,
|
|
beneficiary.Id,
|
|
)
|
|
if updatedAt.Before(oldUpdatedAt) || updatedAt.Equal(oldUpdatedAt) && accountReference == lastProcessedId {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
p.logger.Info("mapping beneficiary to external account error: ", err)
|
|
continue
|
|
}
|
|
accounts = append(accounts, models.PSPAccount{
|
|
Reference: accountReference,
|
|
CreatedAt: createdAt,
|
|
Name: &beneficiary.Name,
|
|
DefaultAsset: pointer.For(currency.FormatAsset(supportedCurrenciesForExternalAccounts, beneficiary.BankAccount.Currency)),
|
|
Metadata: map[string]string{
|
|
"beneficiary_id": beneficiary.Id,
|
|
"bank_account_number": beneficiary.BankAccount.AccountNumber,
|
|
"bank_account_iban": beneficiary.BankAccount.Iban,
|
|
"bank_account_bic": beneficiary.BankAccount.Bic,
|
|
"bank_account_swift_sort_code": beneficiary.BankAccount.SwiftSortCode,
|
|
"bank_account_routing_number": beneficiary.BankAccount.RoutingNumber,
|
|
"bank_account_intermediary_bank_bic": beneficiary.BankAccount.IntermediaryBankBic,
|
|
"updated_at": beneficiary.UpdatedAt,
|
|
},
|
|
Raw: raw,
|
|
})
|
|
|
|
}
|
|
return accounts, nil
|
|
}
|
|
|
|
/*
|
|
*
|
|
There's no unique ID for the beneficiary's bank account, but depending on the type of bank account different fields are
|
|
populated. (see https://api-doc.qonto.com/docs/business-api/d34477c258c06-list-beneficiaries)
|
|
|
|
Swift BIC or SEPA: iban, currency and bic will be present.
|
|
Swift code: account_number, swift_sort_code, intermediary_bank_bic and currency will be present.
|
|
Swift routing number: account_number, routing_number, intermediary_bank_bic and currency will be present.
|
|
|
|
We are not using the intermediary bank bic as part of the reference, as it's not part of the identity of the account
|
|
(it's just an attribute for routing transfers)
|
|
*/
|
|
func generateAccountReference(accountNumber, iban, bic, swiftSortCode, routingNumber, beneficiaryId string) (string, error) {
|
|
switch {
|
|
case iban != "":
|
|
return iban + "-" + bic, nil
|
|
case swiftSortCode != "":
|
|
return accountNumber + "-" + swiftSortCode, nil
|
|
case routingNumber != "":
|
|
return accountNumber + "-" + routingNumber, nil
|
|
default:
|
|
return "", fmt.Errorf("invalid account, unable to generate reference for beneficiary %v", beneficiaryId)
|
|
}
|
|
}
|