Files
CrimsonandGitHub c48bb246b5 feat(Stripe): EN-470 Add balance.available webhooks to Stripe connector (#590)
* Implement Balances Webhooks in Stripe to reduce polling workflows

* Create webhooks on install

* Remove webhook at uninstall time

* Add verifyWebhook function to Stripe

* Pin APIVersion in Stripe Webhook Create call

* Default to root account when receiving balance.update webhook

* Only make call do delete webhooks if webhooks were setup to begin with

* Set up two webhook endpoints - one for connect and one for root account

* Use event.Created to determine balance time in Stripe webhooks

* Start recording root account ID instead of dummy 'root' value

* Create unit test for Stripe create webhooks

* Add tests for Stripe Translate webhook

* Share logic between Stripe balance polling and webhook handle

* Use supported webhooks map

* Update existing stripe webhooks instead of creating new ones at app start time

* Run install sequence for each connector at app start of payments api pods

* Turn of balances periodically for Stripe

* Add nil checks for balance.Available

* Remove potentially sensitive information from error message

* Add migration tool to remove FETCH BALANCES schedules from stripe
2025-11-21 10:25:52 +00:00

99 lines
2.8 KiB
Go

package stripe
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/formancehq/go-libs/v3/currency"
"github.com/formancehq/payments/internal/connectors/plugins/public/stripe/client"
"github.com/formancehq/payments/internal/models"
"github.com/pkg/errors"
)
type externalAccountsState struct {
Timeline client.Timeline `json:"timeline"`
}
func (p *Plugin) fetchNextExternalAccounts(ctx context.Context, req models.FetchNextExternalAccountsRequest) (models.FetchNextExternalAccountsResponse, error) {
var (
oldState externalAccountsState
from models.PSPAccount
)
if req.State != nil {
if err := json.Unmarshal(req.State, &oldState); err != nil {
return models.FetchNextExternalAccountsResponse{}, err
}
}
if req.FromPayload == nil {
return models.FetchNextExternalAccountsResponse{}, errors.New("missing from payload when fetching external accounts")
}
if err := json.Unmarshal(req.FromPayload, &from); err != nil {
return models.FetchNextExternalAccountsResponse{}, err
}
// fetch next external accounts can be skipped in this case (eg. it is the root account)
if from.Reference == p.client.GetRootAccountID() {
p.logger.WithField("account_reference", from.Reference).Debugf("skipping fetch next external accounts for root account")
return models.FetchNextExternalAccountsResponse{}, nil
}
newState := oldState
var accounts []models.PSPAccount
rawAccounts, timeline, hasMore, err := p.client.GetExternalAccounts(
ctx,
resolveAccount(from.Reference),
oldState.Timeline,
int64(req.PageSize),
)
if err != nil {
return models.FetchNextExternalAccountsResponse{}, err
}
newState.Timeline = timeline
for _, acc := range rawAccounts {
raw, err := json.Marshal(acc)
if err != nil {
return models.FetchNextExternalAccountsResponse{}, err
}
metadata := make(map[string]string)
for k, v := range acc.Metadata {
metadata[k] = v
}
defaultAsset := currency.FormatAsset(supportedCurrenciesWithDecimal, string(acc.Currency))
if acc.Account == nil {
return models.FetchNextExternalAccountsResponse{}, fmt.Errorf("internal account %q is missing from response for %q", from.Reference, acc.ID)
}
// Use import time so bank accounts don't all have a creation time of 1970-01-01T00:00:00Z
accountCreated := time.Now().UTC()
if acc.Account.Created > 0 {
accountCreated = time.Unix(acc.Account.Created, 0).UTC()
}
accounts = append(accounts, models.PSPAccount{
Reference: acc.ID,
CreatedAt: accountCreated,
DefaultAsset: &defaultAsset,
Raw: raw,
Metadata: metadata,
})
}
payload, err := json.Marshal(newState)
if err != nil {
return models.FetchNextExternalAccountsResponse{}, err
}
return models.FetchNextExternalAccountsResponse{
ExternalAccounts: accounts,
NewState: payload,
HasMore: hasMore,
}, nil
}