mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
* Check if connector is installed before launching handle webhook workflow * Ensure we return http 4xx status in webhooks if the connector is no longer installed Co-Authored-By: Claude Sonnet 4.6 <[email protected]> --------- Co-authored-by: Claude Sonnet 4.6 <[email protected]>
1104 lines
33 KiB
Go
1104 lines
33 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/formancehq/go-libs/v3/bun/bunpaginate"
|
|
"github.com/formancehq/go-libs/v3/logging"
|
|
"github.com/formancehq/payments/internal/connectors/engine/plugins"
|
|
"github.com/formancehq/payments/internal/connectors/engine/workflow"
|
|
"github.com/formancehq/payments/internal/connectors/plugins/registry"
|
|
"github.com/formancehq/payments/internal/models"
|
|
"github.com/formancehq/payments/internal/otel"
|
|
"github.com/formancehq/payments/internal/storage"
|
|
errorsutils "github.com/formancehq/payments/internal/utils/errors"
|
|
"github.com/go-playground/validator/v10"
|
|
"github.com/google/uuid"
|
|
"github.com/pkg/errors"
|
|
"go.temporal.io/api/enums/v1"
|
|
"go.temporal.io/sdk/client"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
//go:generate mockgen -source engine.go -destination engine_generated.go -package engine . Engine
|
|
type Engine interface {
|
|
// Install a connector with the given provider and configuration.
|
|
InstallConnector(ctx context.Context, provider string, rawConfig json.RawMessage) (models.ConnectorID, error)
|
|
// Uninstall a connector with the given ID.
|
|
UninstallConnector(ctx context.Context, connectorID models.ConnectorID) (models.Task, error)
|
|
// Reset a connector with the given ID, by uninstalling and reinstalling it.
|
|
ResetConnector(ctx context.Context, connectorID models.ConnectorID) (models.Task, error)
|
|
// Update a connector with the given configuration.
|
|
UpdateConnector(ctx context.Context, connectorID models.ConnectorID, rawConfig json.RawMessage) error
|
|
|
|
// Create a Formance account, no call to the plugin, just a creation
|
|
// of an account in the database related to the provided connector id.
|
|
CreateFormanceAccount(ctx context.Context, account models.Account) error
|
|
// Create a Formance payment, no call to the plugin, just a creation
|
|
// of a payment in the database related to the provided connector id.
|
|
CreateFormancePayment(ctx context.Context, payment models.Payment) error
|
|
// Create a Formance payment initiation, no call to the plugin, just a creation
|
|
// of a payment initiation in the database and the sending of the related event.
|
|
CreateFormancePaymentInitiation(ctx context.Context, paymentInitiation models.PaymentInitiation, adj models.PaymentInitiationAdjustment) error
|
|
|
|
// Forward a bank account to the given connector, which will create it
|
|
// in the external system (PSP).
|
|
ForwardBankAccount(ctx context.Context, ba models.BankAccount, connectorID models.ConnectorID, waitResult bool) (models.Task, error)
|
|
// Create a transfer between two accounts on the given connector (PSP).
|
|
CreateTransfer(ctx context.Context, piID models.PaymentInitiationID, attempt int, waitResult bool) (models.Task, error)
|
|
// Reverse a transfer on the given connector (PSP).
|
|
ReverseTransfer(ctx context.Context, reversal models.PaymentInitiationReversal, waitResult bool) (models.Task, error)
|
|
// Create a payout on the given connector (PSP).
|
|
CreatePayout(ctx context.Context, piID models.PaymentInitiationID, attempt int, waitResult bool) (models.Task, error)
|
|
// Reverse a payout on the given connector (PSP).
|
|
ReversePayout(ctx context.Context, reversal models.PaymentInitiationReversal, waitResult bool) (models.Task, error)
|
|
|
|
// We received a webhook, handle it by calling the corresponding plugin to
|
|
// translate it to a formance object and store it.
|
|
HandleWebhook(ctx context.Context, urlPath string, webhook models.Webhook) error
|
|
|
|
// Create a Formance pool composed of accounts.
|
|
CreatePool(ctx context.Context, pool models.Pool) error
|
|
// Add an account to a Formance pool.
|
|
AddAccountToPool(ctx context.Context, id uuid.UUID, accountID models.AccountID) error
|
|
// Remove an account from a Formance pool.
|
|
RemoveAccountFromPool(ctx context.Context, id uuid.UUID, accountID models.AccountID) error
|
|
// Delete a Formance pool.
|
|
DeletePool(ctx context.Context, poolID uuid.UUID) error
|
|
|
|
// Called when the engine is starting, to start all the plugins.
|
|
OnStart(ctx context.Context) error
|
|
// Called when the engine is stopping, to stop all the plugins.
|
|
OnStop(ctx context.Context)
|
|
}
|
|
|
|
type engine struct {
|
|
logger logging.Logger
|
|
|
|
temporalClient client.Client
|
|
storage storage.Storage
|
|
|
|
// plugins is only really present in engine to allow validation of plugin configs prior to insert into the DB
|
|
// other plugin-side work should be performed inside workers and not directly in the engine since we don't
|
|
// have a listener function that checks for plugin updates or installs
|
|
plugins plugins.Plugins
|
|
|
|
stack string
|
|
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
func New(
|
|
logger logging.Logger,
|
|
temporalClient client.Client,
|
|
storage storage.Storage,
|
|
plugins plugins.Plugins,
|
|
stack string,
|
|
) Engine {
|
|
return &engine{
|
|
logger: logger,
|
|
temporalClient: temporalClient,
|
|
storage: storage,
|
|
plugins: plugins,
|
|
stack: stack,
|
|
wg: sync.WaitGroup{},
|
|
}
|
|
}
|
|
|
|
func (e *engine) InstallConnector(ctx context.Context, provider string, rawConfig json.RawMessage) (models.ConnectorID, error) {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.InstallConnector")
|
|
defer span.End()
|
|
|
|
config := models.DefaultConfig()
|
|
if err := json.Unmarshal(rawConfig, &config); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.ConnectorID{}, err
|
|
}
|
|
|
|
if err := config.Validate(); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.ConnectorID{}, errorsutils.NewWrappedError(err, ErrValidation)
|
|
}
|
|
|
|
connector := models.Connector{
|
|
ID: models.ConnectorID{
|
|
Reference: uuid.New(),
|
|
Provider: provider,
|
|
},
|
|
Name: config.Name,
|
|
CreatedAt: time.Now().UTC(),
|
|
Provider: provider,
|
|
Config: rawConfig,
|
|
}
|
|
|
|
err := e.plugins.RegisterPlugin(connector.ID, connector.Provider, connector.Name, config, connector.Config, false)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
if _, ok := err.(validator.ValidationErrors); ok || errors.Is(err, models.ErrInvalidConfig) {
|
|
return models.ConnectorID{}, errorsutils.NewWrappedError(err, ErrValidation)
|
|
}
|
|
return models.ConnectorID{}, err
|
|
}
|
|
|
|
// Detached the context to avoid being in a weird state if request is
|
|
// cancelled in the middle of the operation.
|
|
detachedCtx := context.WithoutCancel(ctx)
|
|
// Since we detached the context, we need to wait for the operation to finish
|
|
// even if the app is shutting down gracefully.
|
|
e.wg.Add(1)
|
|
defer e.wg.Done()
|
|
|
|
if err := e.storage.ConnectorsInstall(detachedCtx, connector); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.ConnectorID{}, err
|
|
}
|
|
|
|
// Launch the workflow
|
|
run, err := e.launchInstallWorkflow(detachedCtx, connector, config)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.ConnectorID{}, err
|
|
}
|
|
|
|
// Wait for installation to complete in order to return connector ID through API
|
|
if err := run.Get(ctx, nil); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.ConnectorID{}, err
|
|
}
|
|
|
|
return connector.ID, nil
|
|
}
|
|
|
|
func (e *engine) UninstallConnector(ctx context.Context, connectorID models.ConnectorID) (models.Task, error) {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.UninstallConnector")
|
|
defer span.End()
|
|
|
|
detachedCtx := context.WithoutCancel(ctx)
|
|
// Since we detached the context, we need to wait for the operation to finish
|
|
// even if the app is shutting down gracefully.
|
|
e.wg.Add(1)
|
|
defer e.wg.Done()
|
|
|
|
if err := e.storage.ConnectorsScheduleForDeletion(detachedCtx, connectorID); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
|
|
now := time.Now()
|
|
id := e.taskIDReferenceFor(IDPrefixConnectorUninstall, connectorID, "")
|
|
task := models.Task{
|
|
// Do not fill the connector ID as it will be deleted
|
|
ID: models.TaskID{
|
|
Reference: id,
|
|
ConnectorID: connectorID,
|
|
},
|
|
Status: models.TASK_STATUS_PROCESSING,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if err := e.storage.TasksUpsert(ctx, task); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
|
|
// Launch the uninstallation in background
|
|
_, err := e.temporalClient.ExecuteWorkflow(
|
|
detachedCtx,
|
|
client.StartWorkflowOptions{
|
|
ID: id,
|
|
TaskQueue: GetDefaultTaskQueue(e.stack),
|
|
WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY,
|
|
WorkflowExecutionErrorWhenAlreadyStarted: false,
|
|
SearchAttributes: map[string]interface{}{
|
|
workflow.SearchAttributeStack: e.stack,
|
|
},
|
|
},
|
|
workflow.RunUninstallConnector,
|
|
workflow.UninstallConnector{
|
|
ConnectorID: connectorID,
|
|
DefaultWorkerName: GetDefaultTaskQueue(e.stack),
|
|
TaskID: &task.ID,
|
|
},
|
|
)
|
|
if err != nil {
|
|
task.Status = models.TASK_STATUS_FAILED
|
|
task.UpdatedAt = time.Now()
|
|
if err := e.storage.TasksUpsert(ctx, task); err != nil {
|
|
e.logger.Errorf("failed to update task status to failed: %v", err)
|
|
}
|
|
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
|
|
return task, nil
|
|
}
|
|
|
|
func (e *engine) ResetConnector(ctx context.Context, connectorID models.ConnectorID) (models.Task, error) {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.ResetConnector")
|
|
defer span.End()
|
|
|
|
detachedCtx := context.WithoutCancel(ctx)
|
|
// Since we detached the context, we need to wait for the operation to finish
|
|
// even if the app is shutting down gracefully.
|
|
e.wg.Add(1)
|
|
defer e.wg.Done()
|
|
|
|
now := time.Now()
|
|
id := e.taskIDReferenceFor(IDPrefixConnectorReset, connectorID, "")
|
|
task := models.Task{
|
|
// Do not fill the connector ID as it will be deleted
|
|
ID: models.TaskID{
|
|
Reference: id,
|
|
ConnectorID: connectorID,
|
|
},
|
|
Status: models.TASK_STATUS_PROCESSING,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if err := e.storage.TasksUpsert(ctx, task); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
|
|
_, err := e.temporalClient.ExecuteWorkflow(
|
|
detachedCtx,
|
|
client.StartWorkflowOptions{
|
|
ID: id,
|
|
TaskQueue: GetDefaultTaskQueue(e.stack),
|
|
WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY,
|
|
WorkflowExecutionErrorWhenAlreadyStarted: false,
|
|
SearchAttributes: map[string]interface{}{
|
|
workflow.SearchAttributeStack: e.stack,
|
|
},
|
|
},
|
|
workflow.RunResetConnector,
|
|
workflow.ResetConnector{
|
|
ConnectorID: connectorID,
|
|
DefaultWorkerName: GetDefaultTaskQueue(e.stack),
|
|
TaskID: task.ID,
|
|
},
|
|
)
|
|
if err != nil {
|
|
task.Status = models.TASK_STATUS_FAILED
|
|
task.UpdatedAt = time.Now()
|
|
if err := e.storage.TasksUpsert(ctx, task); err != nil {
|
|
e.logger.Errorf("failed to update task status to failed: %v", err)
|
|
}
|
|
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
|
|
return task, nil
|
|
}
|
|
|
|
func (e *engine) UpdateConnector(ctx context.Context, connectorID models.ConnectorID, rawConfig json.RawMessage) error {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.UpdateConnector")
|
|
defer span.End()
|
|
|
|
config := models.DefaultConfig()
|
|
if err := json.Unmarshal(rawConfig, &config); err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
if err := config.Validate(); err != nil {
|
|
otel.RecordError(span, err)
|
|
return errors.Wrap(ErrValidation, err.Error())
|
|
}
|
|
|
|
connector, err := e.storage.ConnectorsGet(ctx, connectorID)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
if errors.Is(err, storage.ErrNotFound) {
|
|
return fmt.Errorf("connector %w", ErrNotFound)
|
|
}
|
|
}
|
|
|
|
if connector == nil {
|
|
err := fmt.Errorf("connector %w", ErrNotFound)
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
connector.Config = rawConfig
|
|
connector.Name = config.Name
|
|
|
|
err = e.plugins.RegisterPlugin(connector.ID, connector.Provider, connector.Name, config, connector.Config, true)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
if _, ok := err.(validator.ValidationErrors); ok || errors.Is(err, models.ErrInvalidConfig) {
|
|
return errorsutils.NewWrappedError(err, ErrValidation)
|
|
}
|
|
return err
|
|
}
|
|
|
|
if err := e.storage.ConnectorsConfigUpdate(ctx, *connector); err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *engine) CreateFormanceAccount(ctx context.Context, account models.Account) error {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.CreateFormanceAccount")
|
|
defer span.End()
|
|
|
|
capabilities, err := registry.GetCapabilities(models.ToV3Provider(account.ConnectorID.Provider))
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
found := false
|
|
for _, c := range capabilities {
|
|
if c == models.CAPABILITY_ALLOW_FORMANCE_ACCOUNT_CREATION {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
err := errors.New("connector does not support account creation")
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
if err := e.storage.AccountsUpsert(ctx, []models.Account{account}); err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
// Do not wait for sending of events
|
|
_, err = e.temporalClient.ExecuteWorkflow(
|
|
ctx,
|
|
client.StartWorkflowOptions{
|
|
ID: fmt.Sprintf("create-formance-account-send-events-%s-%s-%s", e.stack, account.ConnectorID.String(), account.Reference),
|
|
TaskQueue: GetDefaultTaskQueue(e.stack),
|
|
WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE,
|
|
WorkflowExecutionErrorWhenAlreadyStarted: false,
|
|
SearchAttributes: map[string]interface{}{
|
|
workflow.SearchAttributeStack: e.stack,
|
|
},
|
|
},
|
|
workflow.RunSendEvents,
|
|
workflow.SendEvents{
|
|
Account: &account,
|
|
},
|
|
)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *engine) CreateFormancePayment(ctx context.Context, payment models.Payment) error {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.CreateFormancePayment")
|
|
defer span.End()
|
|
|
|
capabilities, err := registry.GetCapabilities(models.ToV3Provider(payment.ConnectorID.Provider))
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
found := false
|
|
for _, c := range capabilities {
|
|
if c == models.CAPABILITY_ALLOW_FORMANCE_PAYMENT_CREATION {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
err := errors.New("connector does not support payment creation")
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
if err := e.storage.PaymentsUpsert(ctx, []models.Payment{payment}); err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
// Do not wait for sending of events
|
|
_, err = e.temporalClient.ExecuteWorkflow(
|
|
ctx,
|
|
client.StartWorkflowOptions{
|
|
ID: fmt.Sprintf("create-formance-payment-send-events-%s-%s-%s", e.stack, payment.ConnectorID.String(), payment.Reference),
|
|
TaskQueue: GetDefaultTaskQueue(e.stack),
|
|
WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE,
|
|
WorkflowExecutionErrorWhenAlreadyStarted: false,
|
|
SearchAttributes: map[string]interface{}{
|
|
workflow.SearchAttributeStack: e.stack,
|
|
},
|
|
},
|
|
workflow.RunSendEvents,
|
|
workflow.SendEvents{
|
|
Payment: &payment,
|
|
},
|
|
)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *engine) CreateFormancePaymentInitiation(ctx context.Context, pi models.PaymentInitiation, adj models.PaymentInitiationAdjustment) error {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.CreateFormancePaymentInitiation")
|
|
defer span.End()
|
|
|
|
if err := e.storage.PaymentInitiationsInsert(ctx, pi, adj); err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
// Do not wait for sending of events
|
|
_, err := e.temporalClient.ExecuteWorkflow(
|
|
ctx,
|
|
client.StartWorkflowOptions{
|
|
ID: fmt.Sprintf("create-payment-initiation-send-events-%s-%s-%s", e.stack, pi.ConnectorID.String(), pi.Reference),
|
|
TaskQueue: GetDefaultTaskQueue(e.stack),
|
|
WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE,
|
|
WorkflowExecutionErrorWhenAlreadyStarted: false,
|
|
SearchAttributes: map[string]interface{}{
|
|
workflow.SearchAttributeStack: e.stack,
|
|
},
|
|
},
|
|
workflow.RunSendEvents,
|
|
workflow.SendEvents{
|
|
PaymentInitiation: &pi,
|
|
PaymentInitiationAdjustment: &adj,
|
|
},
|
|
)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *engine) ForwardBankAccount(ctx context.Context, ba models.BankAccount, connectorID models.ConnectorID, waitResult bool) (models.Task, error) {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.ForwardBankAccount")
|
|
defer span.End()
|
|
|
|
if _, err := e.storage.ConnectorsGet(ctx, connectorID); err != nil {
|
|
otel.RecordError(span, err)
|
|
if errors.Is(err, storage.ErrNotFound) {
|
|
return models.Task{}, fmt.Errorf("connector %w", ErrNotFound)
|
|
}
|
|
return models.Task{}, err
|
|
}
|
|
|
|
id := e.taskIDReferenceFor(IDPrefixBankAccountCreate, connectorID, ba.ID.String())
|
|
now := time.Now().UTC()
|
|
task := models.Task{
|
|
ID: models.TaskID{
|
|
Reference: id,
|
|
ConnectorID: connectorID,
|
|
},
|
|
ConnectorID: &connectorID,
|
|
Status: models.TASK_STATUS_PROCESSING,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if err := e.storage.TasksUpsert(ctx, task); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
|
|
run, err := e.temporalClient.ExecuteWorkflow(
|
|
ctx,
|
|
client.StartWorkflowOptions{
|
|
ID: id,
|
|
TaskQueue: GetDefaultTaskQueue(e.stack),
|
|
WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE,
|
|
WorkflowExecutionErrorWhenAlreadyStarted: false,
|
|
SearchAttributes: map[string]interface{}{
|
|
workflow.SearchAttributeStack: e.stack,
|
|
},
|
|
},
|
|
workflow.RunCreateBankAccount,
|
|
workflow.CreateBankAccount{
|
|
TaskID: task.ID,
|
|
ConnectorID: connectorID,
|
|
BankAccount: ba,
|
|
},
|
|
)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
|
|
if waitResult {
|
|
// Wait for bank account creation to complete
|
|
if err := run.Get(ctx, nil); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
}
|
|
|
|
return task, nil
|
|
}
|
|
|
|
func (e *engine) CreateTransfer(ctx context.Context, piID models.PaymentInitiationID, attempt int, waitResult bool) (models.Task, error) {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.CreateTransfer")
|
|
defer span.End()
|
|
|
|
id := models.TaskIDReference(fmt.Sprintf("create-transfer-%s-%d", e.stack, attempt), piID.ConnectorID, piID.String())
|
|
|
|
now := time.Now().UTC()
|
|
task := models.Task{
|
|
ID: models.TaskID{
|
|
Reference: id,
|
|
ConnectorID: piID.ConnectorID,
|
|
},
|
|
ConnectorID: &piID.ConnectorID,
|
|
Status: models.TASK_STATUS_PROCESSING,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if err := e.storage.TasksUpsert(ctx, task); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
|
|
run, err := e.temporalClient.ExecuteWorkflow(
|
|
ctx,
|
|
client.StartWorkflowOptions{
|
|
ID: id,
|
|
TaskQueue: GetDefaultTaskQueue(e.stack),
|
|
WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE,
|
|
WorkflowExecutionErrorWhenAlreadyStarted: false,
|
|
SearchAttributes: map[string]interface{}{
|
|
workflow.SearchAttributeStack: e.stack,
|
|
},
|
|
},
|
|
workflow.RunCreateTransfer,
|
|
workflow.CreateTransfer{
|
|
TaskID: task.ID,
|
|
ConnectorID: piID.ConnectorID,
|
|
PaymentInitiationID: piID,
|
|
},
|
|
)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
|
|
if waitResult {
|
|
// Wait for bank account creation to complete
|
|
if err := run.Get(ctx, nil); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, handleWorkflowError(err)
|
|
}
|
|
}
|
|
|
|
return task, nil
|
|
}
|
|
|
|
func (e *engine) ReverseTransfer(ctx context.Context, reversal models.PaymentInitiationReversal, waitResult bool) (models.Task, error) {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.ReverseTransfer")
|
|
defer span.End()
|
|
|
|
detachedCtx := context.WithoutCancel(ctx)
|
|
e.wg.Add(1)
|
|
defer e.wg.Done()
|
|
|
|
id := models.TaskIDReference(fmt.Sprintf("reverse-transfer-%s-%s", e.stack, reversal.CreatedAt.String()), reversal.ConnectorID, reversal.ID.String())
|
|
now := time.Now().UTC()
|
|
task := models.Task{
|
|
ID: models.TaskID{
|
|
Reference: id,
|
|
ConnectorID: reversal.ConnectorID,
|
|
},
|
|
ConnectorID: &reversal.ConnectorID,
|
|
Status: models.TASK_STATUS_PROCESSING,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := e.storage.TasksUpsert(detachedCtx, task); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
|
|
run, err := e.temporalClient.ExecuteWorkflow(
|
|
detachedCtx,
|
|
client.StartWorkflowOptions{
|
|
ID: id,
|
|
TaskQueue: GetDefaultTaskQueue(e.stack),
|
|
WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE,
|
|
WorkflowExecutionErrorWhenAlreadyStarted: false,
|
|
SearchAttributes: map[string]interface{}{
|
|
workflow.SearchAttributeStack: e.stack,
|
|
},
|
|
},
|
|
workflow.RunReverseTransfer,
|
|
workflow.ReverseTransfer{
|
|
TaskID: task.ID,
|
|
ConnectorID: reversal.ConnectorID,
|
|
PaymentInitiationReversalID: reversal.ID,
|
|
},
|
|
)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
|
|
if waitResult {
|
|
// Wait for bank account creation to complete
|
|
// use ctx instead of detachedCtx to allow the caller to cancel the operation
|
|
// and not wait for the result
|
|
if err := run.Get(ctx, nil); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
}
|
|
|
|
return task, nil
|
|
}
|
|
|
|
func (e *engine) CreatePayout(ctx context.Context, piID models.PaymentInitiationID, attempt int, waitResult bool) (models.Task, error) {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.CreatePayout")
|
|
defer span.End()
|
|
|
|
id := models.TaskIDReference(fmt.Sprintf("create-payout-%s-%d", e.stack, attempt), piID.ConnectorID, piID.String())
|
|
|
|
now := time.Now().UTC()
|
|
task := models.Task{
|
|
ID: models.TaskID{
|
|
Reference: id,
|
|
ConnectorID: piID.ConnectorID,
|
|
},
|
|
ConnectorID: &piID.ConnectorID,
|
|
Status: models.TASK_STATUS_PROCESSING,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if err := e.storage.TasksUpsert(ctx, task); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
|
|
run, err := e.temporalClient.ExecuteWorkflow(
|
|
ctx,
|
|
client.StartWorkflowOptions{
|
|
ID: id,
|
|
TaskQueue: GetDefaultTaskQueue(e.stack),
|
|
WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE,
|
|
WorkflowExecutionErrorWhenAlreadyStarted: false,
|
|
SearchAttributes: map[string]interface{}{
|
|
workflow.SearchAttributeStack: e.stack,
|
|
},
|
|
},
|
|
workflow.RunCreatePayout,
|
|
workflow.CreatePayout{
|
|
TaskID: task.ID,
|
|
ConnectorID: piID.ConnectorID,
|
|
PaymentInitiationID: piID,
|
|
},
|
|
)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
|
|
if waitResult {
|
|
// Wait for bank account creation to complete
|
|
if err := run.Get(ctx, nil); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, handleWorkflowError(err)
|
|
}
|
|
}
|
|
|
|
return task, nil
|
|
}
|
|
|
|
func (e *engine) ReversePayout(ctx context.Context, reversal models.PaymentInitiationReversal, waitResult bool) (models.Task, error) {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.ReversePayout")
|
|
defer span.End()
|
|
|
|
detachedCtx := context.WithoutCancel(ctx)
|
|
e.wg.Add(1)
|
|
defer e.wg.Done()
|
|
|
|
id := models.TaskIDReference(fmt.Sprintf("reverse-payout-%s-%s", e.stack, reversal.CreatedAt.String()), reversal.ConnectorID, reversal.ID.String())
|
|
now := time.Now().UTC()
|
|
task := models.Task{
|
|
ID: models.TaskID{
|
|
Reference: id,
|
|
ConnectorID: reversal.ConnectorID,
|
|
},
|
|
ConnectorID: &reversal.ConnectorID,
|
|
Status: models.TASK_STATUS_PROCESSING,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if err := e.storage.TasksUpsert(detachedCtx, task); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
|
|
run, err := e.temporalClient.ExecuteWorkflow(
|
|
detachedCtx,
|
|
client.StartWorkflowOptions{
|
|
ID: id,
|
|
TaskQueue: GetDefaultTaskQueue(e.stack),
|
|
WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE,
|
|
WorkflowExecutionErrorWhenAlreadyStarted: false,
|
|
SearchAttributes: map[string]interface{}{
|
|
workflow.SearchAttributeStack: e.stack,
|
|
},
|
|
},
|
|
workflow.RunReversePayout,
|
|
workflow.ReversePayout{
|
|
TaskID: task.ID,
|
|
ConnectorID: reversal.ConnectorID,
|
|
PaymentInitiationReversalID: reversal.ID,
|
|
},
|
|
)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
|
|
if waitResult {
|
|
// Wait for bank account creation to complete
|
|
// use ctx instead of detachedCtx to allow the caller to cancel the operation
|
|
// and not wait for the result
|
|
if err := run.Get(ctx, nil); err != nil {
|
|
otel.RecordError(span, err)
|
|
return models.Task{}, err
|
|
}
|
|
}
|
|
|
|
return task, nil
|
|
}
|
|
|
|
func (e *engine) HandleWebhook(ctx context.Context, urlPath string, webhook models.Webhook) error {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.HandleWebhook")
|
|
defer span.End()
|
|
|
|
if _, err := e.storage.ConnectorsGet(ctx, webhook.ConnectorID); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := e.temporalClient.ExecuteWorkflow(
|
|
ctx,
|
|
client.StartWorkflowOptions{
|
|
ID: fmt.Sprintf("webhook-%s-%s-%s", e.stack, webhook.ConnectorID.String(), webhook.ID),
|
|
TaskQueue: GetDefaultTaskQueue(e.stack),
|
|
WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE,
|
|
WorkflowExecutionErrorWhenAlreadyStarted: false,
|
|
SearchAttributes: map[string]interface{}{
|
|
workflow.SearchAttributeStack: e.stack,
|
|
},
|
|
},
|
|
workflow.RunHandleWebhooks,
|
|
workflow.HandleWebhooks{
|
|
ConnectorID: webhook.ConnectorID,
|
|
URLPath: urlPath,
|
|
Webhook: webhook,
|
|
},
|
|
); err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *engine) CreatePool(ctx context.Context, pool models.Pool) error {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.CreatePool")
|
|
defer span.End()
|
|
|
|
eg, groupCtx := errgroup.WithContext(ctx)
|
|
for _, accountID := range pool.PoolAccounts {
|
|
aID := accountID
|
|
eg.Go(func() error {
|
|
acc, err := e.storage.AccountsGet(groupCtx, aID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if acc.Type != models.ACCOUNT_TYPE_INTERNAL {
|
|
return fmt.Errorf("account %s is not an internal account: %w", aID, ErrValidation)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
if err := eg.Wait(); err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
if err := e.storage.PoolsUpsert(ctx, pool); err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
// Do not wait for sending of events
|
|
_, err := e.temporalClient.ExecuteWorkflow(
|
|
ctx,
|
|
client.StartWorkflowOptions{
|
|
ID: fmt.Sprintf("pools-creation-%s-%s", e.stack, pool.ID.String()),
|
|
TaskQueue: GetDefaultTaskQueue(e.stack),
|
|
WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE,
|
|
WorkflowExecutionErrorWhenAlreadyStarted: false,
|
|
SearchAttributes: map[string]interface{}{
|
|
workflow.SearchAttributeStack: e.stack,
|
|
},
|
|
},
|
|
workflow.RunSendEvents,
|
|
workflow.SendEvents{
|
|
PoolsCreation: &pool,
|
|
},
|
|
)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *engine) AddAccountToPool(ctx context.Context, id uuid.UUID, accountID models.AccountID) error {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.AddAccountToPool")
|
|
defer span.End()
|
|
|
|
// Check if the account exists and if it's an INTERNAL account
|
|
account, err := e.storage.AccountsGet(ctx, accountID)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
if account.Type != models.ACCOUNT_TYPE_INTERNAL {
|
|
return fmt.Errorf("account %s is not an internal account: %w", accountID, ErrValidation)
|
|
}
|
|
|
|
if err := e.storage.PoolsAddAccount(ctx, id, accountID); err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
detachedCtx := context.WithoutCancel(ctx)
|
|
// Since we detached the context, we need to wait for the operation to finish
|
|
// even if the app is shutting down gracefully.
|
|
e.wg.Add(1)
|
|
defer e.wg.Done()
|
|
|
|
pool, err := e.storage.PoolsGet(detachedCtx, id)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
// Do not wait for sending of events
|
|
_, err = e.temporalClient.ExecuteWorkflow(
|
|
detachedCtx,
|
|
client.StartWorkflowOptions{
|
|
ID: fmt.Sprintf("pools-add-account-%s-%s-%s", e.stack, pool.ID.String(), accountID.String()),
|
|
TaskQueue: GetDefaultTaskQueue(e.stack),
|
|
WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE,
|
|
WorkflowExecutionErrorWhenAlreadyStarted: false,
|
|
SearchAttributes: map[string]interface{}{
|
|
workflow.SearchAttributeStack: e.stack,
|
|
},
|
|
},
|
|
workflow.RunSendEvents,
|
|
workflow.SendEvents{
|
|
PoolsCreation: pool,
|
|
},
|
|
)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *engine) RemoveAccountFromPool(ctx context.Context, id uuid.UUID, accountID models.AccountID) error {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.RemoveAccountFromPool")
|
|
defer span.End()
|
|
|
|
if err := e.storage.PoolsRemoveAccount(ctx, id, accountID); err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
detachedCtx := context.WithoutCancel(ctx)
|
|
// Since we detached the context, we need to wait for the operation to finish
|
|
// even if the app is shutting down gracefully.
|
|
e.wg.Add(1)
|
|
defer e.wg.Done()
|
|
|
|
pool, err := e.storage.PoolsGet(detachedCtx, id)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
// Do not wait for sending of events
|
|
_, err = e.temporalClient.ExecuteWorkflow(
|
|
detachedCtx,
|
|
client.StartWorkflowOptions{
|
|
ID: fmt.Sprintf("pools-remove-account-%s-%s-%s", e.stack, pool.ID.String(), accountID.String()),
|
|
TaskQueue: GetDefaultTaskQueue(e.stack),
|
|
WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE,
|
|
WorkflowExecutionErrorWhenAlreadyStarted: false,
|
|
SearchAttributes: map[string]interface{}{
|
|
workflow.SearchAttributeStack: e.stack,
|
|
},
|
|
},
|
|
workflow.RunSendEvents,
|
|
workflow.SendEvents{
|
|
PoolsCreation: pool,
|
|
},
|
|
)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *engine) DeletePool(ctx context.Context, poolID uuid.UUID) error {
|
|
ctx, span := otel.Tracer().Start(ctx, "engine.DeletePool")
|
|
defer span.End()
|
|
|
|
deleted, err := e.storage.PoolsDelete(ctx, poolID)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
|
|
if deleted {
|
|
// Do not wait for sending of events
|
|
_, err := e.temporalClient.ExecuteWorkflow(
|
|
ctx,
|
|
client.StartWorkflowOptions{
|
|
ID: fmt.Sprintf("pools-deletion-%s-%s", e.stack, poolID.String()),
|
|
TaskQueue: GetDefaultTaskQueue(e.stack),
|
|
WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE,
|
|
WorkflowExecutionErrorWhenAlreadyStarted: false,
|
|
SearchAttributes: map[string]interface{}{
|
|
workflow.SearchAttributeStack: e.stack,
|
|
},
|
|
},
|
|
workflow.RunSendEvents,
|
|
workflow.SendEvents{
|
|
PoolsDeletion: &poolID,
|
|
},
|
|
)
|
|
if err != nil {
|
|
otel.RecordError(span, err)
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *engine) OnStop(ctx context.Context) {
|
|
waitingChan := make(chan struct{})
|
|
go func() {
|
|
e.wg.Wait()
|
|
close(waitingChan)
|
|
}()
|
|
|
|
select {
|
|
case <-waitingChan:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
|
|
func (e *engine) OnStart(ctx context.Context) error {
|
|
query := storage.NewListConnectorsQuery(
|
|
bunpaginate.NewPaginatedQueryOptions(storage.ConnectorQuery{}).
|
|
WithPageSize(100),
|
|
)
|
|
|
|
for {
|
|
connectors, err := e.storage.ConnectorsList(ctx, query)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, connector := range connectors.Data {
|
|
if err := e.onStartPlugin(ctx, connector); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !connectors.HasMore {
|
|
break
|
|
}
|
|
|
|
err = bunpaginate.UnmarshalCursor(connectors.Next, &query)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *engine) onStartPlugin(ctx context.Context, connector models.Connector) error {
|
|
// Even if the connector is scheduled for deletion, we still need to register
|
|
// the plugin to be able to handle the uninstallation.
|
|
// It will be unregistered when the uninstallation is done in the workflow
|
|
// after the deletion of the connector entry in the database.
|
|
config := models.DefaultConfig()
|
|
if err := json.Unmarshal(connector.Config, &config); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !connector.ScheduledForDeletion {
|
|
// Launch the workflow
|
|
if _, err := e.launchInstallWorkflow(ctx, connector, config); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *engine) launchInstallWorkflow(ctx context.Context, connector models.Connector, config models.Config) (client.WorkflowRun, error) {
|
|
return e.temporalClient.ExecuteWorkflow(
|
|
ctx,
|
|
client.StartWorkflowOptions{
|
|
ID: e.taskIDReferenceFor(IDPrefixConnectorInstall, connector.ID, ""),
|
|
TaskQueue: GetDefaultTaskQueue(e.stack),
|
|
WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE,
|
|
WorkflowExecutionErrorWhenAlreadyStarted: false,
|
|
SearchAttributes: map[string]interface{}{
|
|
workflow.SearchAttributeStack: e.stack,
|
|
},
|
|
},
|
|
workflow.RunInstallConnector,
|
|
workflow.InstallConnector{
|
|
ConnectorID: connector.ID,
|
|
Config: config,
|
|
},
|
|
)
|
|
}
|
|
|
|
var _ Engine = &engine{}
|