Bootstrap copied BFF and local accounts with public Sendico contracts (#34)

This commit is contained in:
Stephan D committed 2026-09-12 13:37:48 +02:00
1 parent 67dc5b5b1e
commit 7062672a87
556 files changed
+42291 -777

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
package model
import (
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
type Filter int
type AccountStatus string
const (
AccountPendingVerification AccountStatus = "pending_verification"
AccountActive AccountStatus = "active"
AccountBlocked AccountStatus = "blocked"
)
type AccountBase struct {
storable.Base `bson:",inline" json:",inline"`
ArchivableBase `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
LastName string `bson:"lastName" json:"lastName"`
AvatarURL *string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"`
Metadata *AccountMetadata `bson:"metadata,omitempty" json:"-"`
// INN is the account owner's taxpayer identifier. It is the canonical source
// of the INN for personal (account-owned) payouts, flowing into
// customer.tax_identifier.
INN string `bson:"inn,omitempty" json:"inn,omitempty"`
}
func (*AccountBase) Collection() string {
return mservice.Accounts
}
type AccountPublic struct {
AccountBase `bson:",inline" json:",inline"`
UserDataBase `bson:",inline" json:",inline"`
}
type Account struct {
AccountPublic `bson:",inline" json:",inline"`
Password string `bson:"password" json:"-"` // password hash
Status AccountStatus `bson:"status" json:"-"`
}
func (a *Account) Copy() *Account {
if a == nil {
return nil
}
copied := *a
copied.Metadata = a.Metadata.Copy()
return &copied
}
func (a *Account) IsActive() bool {
return a.Status == AccountActive
}
func (a *Account) HashPassword() error {
key, err := bcrypt.GenerateFromPassword([]byte(a.Password), bcrypt.DefaultCost)
if err == nil {
a.Password = string(key)
}
return err
}
func (a *Account) MatchPassword(password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(a.Password), []byte(password))
return err == nil
}
func AnonymousUserName(orgRef bson.ObjectID) string {
return "anonymous@" + orgRef.Hex()
}
func AccountIsAnonymous(account *UserDataBase, orgRef bson.ObjectID) bool {
if account == nil {
return false
}
return AnonymousUserName(orgRef) == account.Login
}
type AccountBound interface {
GetAccountRef() bson.ObjectID
}
type AccountBoundStorable interface {
storable.Storable
OrganizationBound
GetAccountRef() *bson.ObjectID
}
const (
AccountRefField = "accountRef"
)
type AccountBoundBase struct {
storable.Base `bson:",inline" json:",inline"`
OrganizationBoundBase `bson:",inline" json:",inline"`
AccountRef *bson.ObjectID `bson:"accountRef,omitempty" json:"accountRef,omitempty"`
}
func (a *AccountBoundBase) GetAccountRef() *bson.ObjectID {
return a.AccountRef
}
+45
View File
@@ -0,0 +1,45 @@
package model
import "time"
type AccountMetadata struct {
PersonalLedgerProvisioning *LedgerProvisioningMetadata `bson:"personalLedgerProvisioning,omitempty" json:"-"`
}
type LedgerProvisioningMetadata struct {
Source string `bson:"source,omitempty" json:"-"`
InvitationRef string `bson:"invitationRef,omitempty" json:"-"`
TargetCurrencies []string `bson:"targetCurrencies,omitempty" json:"-"`
Created []string `bson:"created,omitempty" json:"-"`
AlreadyExisting []string `bson:"alreadyExisting,omitempty" json:"-"`
Failed []LedgerProvisioningFailure `bson:"failed,omitempty" json:"-"`
Outcome string `bson:"outcome,omitempty" json:"-"`
LastErrorClass string `bson:"lastErrorClass,omitempty" json:"-"`
LastAttemptAt time.Time `bson:"lastAttemptAt,omitempty" json:"-"`
}
type LedgerProvisioningFailure struct {
Currency string `bson:"currency,omitempty" json:"-"`
ErrorClass string `bson:"errorClass,omitempty" json:"-"`
}
func (m *AccountMetadata) Copy() *AccountMetadata {
if m == nil {
return nil
}
copied := *m
copied.PersonalLedgerProvisioning = m.PersonalLedgerProvisioning.Copy()
return &copied
}
func (m *LedgerProvisioningMetadata) Copy() *LedgerProvisioningMetadata {
if m == nil {
return nil
}
copied := *m
copied.TargetCurrencies = append([]string(nil), m.TargetCurrencies...)
copied.Created = append([]string(nil), m.Created...)
copied.AlreadyExisting = append([]string(nil), m.AlreadyExisting...)
copied.Failed = append([]LedgerProvisioningFailure(nil), m.Failed...)
return &copied
}
@@ -0,0 +1,57 @@
package account_role
import (
"strings"
)
type AccountRole string
const (
AccountRoleOperating AccountRole = "operating"
AccountRoleHold AccountRole = "hold"
AccountRoleTransit AccountRole = "transit"
AccountRoleSettlement AccountRole = "settlement"
AccountRoleClearing AccountRole = "clearing"
AccountRolePending AccountRole = "pending"
AccountRoleReserve AccountRole = "reserve"
AccountRoleLiquidity AccountRole = "liquidity"
AccountRoleFee AccountRole = "fee"
AccountRoleChargeback AccountRole = "chargeback"
AccountRoleAdjustment AccountRole = "adjustment"
)
const (
MetadataKeyFromRole = "from_role"
MetadataKeyToRole = "to_role"
)
func Parse(value string) (AccountRole, bool) {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "ACCOUNT_ROLE_OPERATING", "OPERATING":
return AccountRoleOperating, true
case "ACCOUNT_ROLE_HOLD", "HOLD":
return AccountRoleHold, true
case "ACCOUNT_ROLE_TRANSIT", "TRANSIT":
return AccountRoleTransit, true
case "ACCOUNT_ROLE_SETTLEMENT", "SETTLEMENT":
return AccountRoleSettlement, true
case "ACCOUNT_ROLE_CLEARING", "CLEARING":
return AccountRoleClearing, true
case "ACCOUNT_ROLE_PENDING", "PENDING":
return AccountRolePending, true
case "ACCOUNT_ROLE_RESERVE", "RESERVE":
return AccountRoleReserve, true
case "ACCOUNT_ROLE_LIQUIDITY", "LIQUIDITY":
return AccountRoleLiquidity, true
case "ACCOUNT_ROLE_FEE", "FEE":
return AccountRoleFee, true
case "ACCOUNT_ROLE_CHARGEBACK", "CHARGEBACK":
return AccountRoleChargeback, true
case "ACCOUNT_ROLE_ADJUSTMENT", "ADJUSTMENT":
return AccountRoleAdjustment, true
case "ACCOUNT_ROLE_UNSPECIFIED", "UNSPECIFIED", "":
return "", true
default:
return "", false
}
}
+51
View File
@@ -0,0 +1,51 @@
package model
import (
"testing"
"time"
)
func TestAccountCopyDeepCopiesMetadata(t *testing.T) {
account := &Account{
AccountPublic: AccountPublic{
AccountBase: AccountBase{
Metadata: &AccountMetadata{
PersonalLedgerProvisioning: &LedgerProvisioningMetadata{
Source: "invitation",
InvitationRef: "invitation-ref",
TargetCurrencies: []string{"RUB", "USDT"},
Created: []string{"RUB"},
AlreadyExisting: []string{"USDT"},
Failed: []LedgerProvisioningFailure{{Currency: "EUR", ErrorClass: "timeout"}},
Outcome: "partial_failure",
LastErrorClass: "timeout",
LastAttemptAt: time.Now().UTC(),
},
},
},
UserDataBase: UserDataBase{Login: "invitee@example.com"},
},
Password: "secret",
Status: AccountActive,
}
copied := account.Copy()
copied.Metadata.PersonalLedgerProvisioning.TargetCurrencies[0] = "EUR"
copied.Metadata.PersonalLedgerProvisioning.Created[0] = "EUR"
copied.Metadata.PersonalLedgerProvisioning.AlreadyExisting[0] = "EUR"
copied.Metadata.PersonalLedgerProvisioning.Failed[0].ErrorClass = "ledger_unavailable"
metadata := account.Metadata.PersonalLedgerProvisioning
if metadata.TargetCurrencies[0] != "RUB" {
t.Fatalf("expected original target currency to remain RUB, got %q", metadata.TargetCurrencies[0])
}
if metadata.Created[0] != "RUB" {
t.Fatalf("expected original created currency to remain RUB, got %q", metadata.Created[0])
}
if metadata.AlreadyExisting[0] != "USDT" {
t.Fatalf("expected original existing currency to remain USDT, got %q", metadata.AlreadyExisting[0])
}
if metadata.Failed[0].ErrorClass != "timeout" {
t.Fatalf("expected original failure class to remain timeout, got %q", metadata.Failed[0].ErrorClass)
}
}
+18
View File
@@ -0,0 +1,18 @@
package model
type Archivable interface {
IsArchived() bool
SetArchived(archived bool)
}
type ArchivableBase struct {
Archived bool `bson:"isArchived" json:"isArchived"`
}
func (a *ArchivableBase) IsArchived() bool {
return a.Archived
}
func (a *ArchivableBase) SetArchived(archived bool) {
a.Archived = archived
}
+84
View File
@@ -0,0 +1,84 @@
package model
import (
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Action represents a permissible action on a resource.
type Action string
// Common actions for resources.
const (
ActionCreate Action = "create" // Create a resource
ActionRead Action = "read" // Read or view a resource
ActionUpdate Action = "update" // Update or modify a resource
ActionDelete Action = "delete" // Delete a resource
)
// Effect determines whether an action is allowed or denied.
type Effect string
const (
EffectAllow Effect = "allow" // Permit the action
EffectDeny Effect = "deny" // Deny the action
)
// RoleDescription provides metadata about a role.
type RoleDescription struct {
storable.Base `bson:",inline" json:",inline"` // Base fields for MongoDB documents
Describable `bson:",inline" json:",inline"` // Name and description fields
OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` // Organization associated with the role
}
// Collection specifies the MongoDB collection for RoleDescription.
func (*RoleDescription) Collection() string {
return mservice.Roles
}
// Role represents a role assignment for an account within an organization.
type Role struct {
AccountRef bson.ObjectID `bson:"accountRef" json:"accountRef"` // Account assigned to the role
DescriptionRef bson.ObjectID `bson:"descriptionRef" json:"descriptionRef"` // Reference to the role's description
OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` // Organization where the role is applicable
}
// ActionEffect represents a combination of an action and its effect (allow/deny).
type ActionEffect struct {
Action Action `bson:"action" json:"action"` // The action to perform (e.g., read, write)
Effect Effect `bson:"effect" json:"effect"` // Whether the action is allowed or denied
}
// Policy defines access control rules for a role within an organization.
type Policy struct {
OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` // Organization associated with the policy
DescriptionRef bson.ObjectID `bson:"descriptionRef" json:"descriptionRef"` // Reference to the policy's metadata
ObjectRef *bson.ObjectID `bson:"objectRef,omitempty" json:"objectRef,omitempty"` // Target object (NilObjectID for all objects)
Effect ActionEffect `bson:"effect" json:"effect"` // Action and effect for the policy
}
// RolePolicy defines access control rules for a role within an organization.
type RolePolicy struct {
Policy `bson:",inline" json:",inline"`
RoleDescriptionRef bson.ObjectID `bson:"roleDescriptionRef" json:"roleDescriptionRef"` // Reference to the associated role
}
// PolicyDescription provides metadata for policies.
type PolicyDescription struct {
storable.Base `bson:",inline" json:",inline"` // Base fields for MongoDB documents
Describable `bson:",inline" json:",inline"` // Name and description fields
ResourceTypes *[]mservice.Type `bson:"resourceTypes,omitempty" json:"resourceTypes,omitempty"` // nil for custom policies, non-nil for built-in permissisons
OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"` // nil for built-in policies, non-nil for custom
}
// Collection specifies the MongoDB collection for PolicyDescription.
func (*PolicyDescription) Collection() string {
return mservice.Policies
}
// Permission ties a policy to a specific account.
type Permission struct {
RolePolicy `bson:",inline" json:",inline"` // Embedded policy definition
AccountRef bson.ObjectID `bson:"accountRef" json:"accountRef"` // Account assigned the permission
}
+169
View File
@@ -0,0 +1,169 @@
package model
import (
"fmt"
"math"
"strconv"
"strings"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
type CardPaymentData struct {
Pan string `bson:"pan" json:"pan"`
FirstName string `bson:"firstName" json:"firstName"`
LastName string `bson:"lastName" json:"lastName"`
ExpMonth *int `bson:"exp_month" json:"exp_month"`
ExpYear *int `bson:"exp_year" json:"exp_year"`
Network string `bson:"network,omitempty" json:"network,omitempty"`
Country string `bson:"country,omitempty" json:"country,omitempty"`
}
func (d *CardPaymentData) UnmarshalBSON(data []byte) error {
var raw map[string]any
if err := bson.Unmarshal(data, &raw); err != nil {
return err
}
d.Pan = paymentDataString(raw["pan"])
d.FirstName = firstPaymentDataString(raw, "firstName", "first_name")
d.LastName = firstPaymentDataString(raw, "lastName", "last_name")
d.ExpMonth = firstPaymentDataInt(raw, "exp_month", "expMonth")
d.ExpYear = firstPaymentDataInt(raw, "exp_year", "expYear")
d.Network = paymentDataString(raw["network"])
d.Country = paymentDataString(raw["country"])
return nil
}
func (m *PaymentMethod) AsCard() (*CardPaymentData, error) {
if m.Type != PaymentTypeCard {
return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not card", m.Type), "type")
}
var d CardPaymentData
if err := bson.Unmarshal(m.Data, &d); err != nil {
return nil, err
}
return &d, nil
}
func firstPaymentDataString(raw map[string]any, keys ...string) string {
for _, key := range keys {
if value := paymentDataString(raw[key]); value != "" {
return value
}
}
return ""
}
// paymentDataDocument reads a nested payment data document as a map, whatever
// document shape the BSON decoder produced: a generic decode yields bson.D,
// while a typed or hand-built payload yields a map.
func paymentDataDocument(value any) map[string]any {
switch document := value.(type) {
case map[string]any:
return document
case bson.M:
return document
case bson.D:
out := make(map[string]any, len(document))
for _, element := range document {
out[element.Key] = element.Value
}
return out
default:
return nil
}
}
func firstPaymentDataInt(raw map[string]any, keys ...string) *int {
for _, key := range keys {
if value := paymentDataInt(raw[key]); value != nil {
return value
}
}
return nil
}
func paymentDataInt(value any) *int {
switch v := value.(type) {
case nil:
return nil
case int:
return &v
case int32:
out := int(v)
return &out
case int64:
out := int(v)
return &out
case uint:
out := int(v)
return &out
case uint32:
out := int(v)
return &out
case uint64:
return paymentDataIntFromUint64(v)
case float32:
out := int(v)
return &out
case float64:
out := int(v)
return &out
case string:
clean := strings.TrimSpace(v)
if clean == "" {
return nil
}
parsed, err := strconv.Atoi(clean)
if err != nil {
return nil
}
return &parsed
default:
clean := strings.TrimSpace(fmt.Sprint(v))
if clean == "" {
return nil
}
parsed, err := strconv.Atoi(clean)
if err != nil {
return nil
}
return &parsed
}
}
func paymentDataIntFromUint64(value uint64) *int {
if value > uint64(math.MaxInt) {
return nil
}
out := int(value)
return &out
}
func paymentDataString(value any) string {
switch v := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(v)
case int:
return fmt.Sprint(v)
case int32:
return fmt.Sprint(v)
case int64:
return fmt.Sprint(v)
case uint:
return fmt.Sprint(v)
case uint32:
return fmt.Sprint(v)
case uint64:
return fmt.Sprint(v)
case float32:
return strconv.FormatFloat(float64(v), 'f', -1, 32)
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
+68
View File
@@ -0,0 +1,68 @@
package model
import (
"testing"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestPaymentMethodAsCardReadsStoredSnakeCaseExpiry(t *testing.T) {
data, err := bson.Marshal(bson.M{
"pan": "2200001100000001",
"firstName": "S",
"lastName": "S",
"exp_month": 12.0,
"exp_year": 2031.0,
"country": nil,
})
if err != nil {
t.Fatalf("marshal card data: %v", err)
}
card, err := (&PaymentMethod{
Type: PaymentTypeCard,
Data: data,
}).AsCard()
if err != nil {
t.Fatalf("AsCard returned error: %v", err)
}
if got, want := card.ExpMonth, 12; got == nil || *got != want {
t.Fatalf("exp_month mismatch: got=%v want=%d", got, want)
}
if got, want := card.ExpYear, 2031; got == nil || *got != want {
t.Fatalf("exp_year mismatch: got=%v want=%d", got, want)
}
}
func TestCardPaymentDataMarshalUsesStoredSnakeCaseExpiry(t *testing.T) {
expMonth := 12
expYear := 2031
raw, err := bson.Marshal(CardPaymentData{
Pan: "2200001100000001",
FirstName: "S",
LastName: "S",
ExpMonth: &expMonth,
ExpYear: &expYear,
})
if err != nil {
t.Fatalf("marshal card data: %v", err)
}
var data bson.M
if err := bson.Unmarshal(raw, &data); err != nil {
t.Fatalf("unmarshal card data: %v", err)
}
if got, want := paymentDataInt(data["exp_month"]), 12; got == nil || *got != want {
t.Fatalf("stored exp_month mismatch: got=%v want=%d", got, want)
}
if got, want := paymentDataInt(data["exp_year"]), 2031; got == nil || *got != want {
t.Fatalf("stored exp_year mismatch: got=%v want=%d", got, want)
}
if _, ok := data["expMonth"]; ok {
t.Fatalf("card data must not store camelCase expMonth: %#v", data)
}
if _, ok := data["expYear"]; ok {
t.Fatalf("card data must not store camelCase expYear: %#v", data)
}
}
+97
View File
@@ -0,0 +1,97 @@
package model
import "strings"
// ChainNetwork identifies a blockchain network for routing and discovery.
type ChainNetwork string
const (
ChainNetworkArbitrumOne ChainNetwork = "arbitrum_one"
ChainNetworkArbitrumSepolia ChainNetwork = "arbitrum_sepolia"
ChainNetworkEthereumMainnet ChainNetwork = "ethereum_mainnet"
ChainNetworkTronMainnet ChainNetwork = "tron_mainnet"
ChainNetworkTronNile ChainNetwork = "tron_nile"
ChainNetworkUnspecified ChainNetwork = ""
)
// AllChainNetworks returns all valid chain networks.
func AllChainNetworks() []ChainNetwork {
return []ChainNetwork{
ChainNetworkArbitrumOne,
ChainNetworkArbitrumSepolia,
ChainNetworkEthereumMainnet,
ChainNetworkTronMainnet,
ChainNetworkTronNile,
}
}
// ParseChainNetwork parses a string into a ChainNetwork.
// Returns the network and true if valid, or empty and false if invalid.
func ParseChainNetwork(value string) (ChainNetwork, bool) {
v := strings.ToLower(strings.TrimSpace(value))
switch v {
case "arbitrum_one", "arbitrum", "arb":
return ChainNetworkArbitrumOne, true
case "arbitrum_sepolia":
return ChainNetworkArbitrumSepolia, true
case "ethereum_mainnet", "ethereum", "eth":
return ChainNetworkEthereumMainnet, true
case "tron_mainnet", "tron":
return ChainNetworkTronMainnet, true
case "tron_nile", "nile":
return ChainNetworkTronNile, true
case "unspecified", "":
return ChainNetworkUnspecified, true
default:
return ChainNetworkUnspecified, false
}
}
// String returns the string representation of the network.
func (n ChainNetwork) String() string {
return string(n)
}
// IsValid returns true if the network is a known valid network.
func (n ChainNetwork) IsValid() bool {
switch n {
case ChainNetworkArbitrumOne,
ChainNetworkArbitrumSepolia,
ChainNetworkEthereumMainnet,
ChainNetworkTronMainnet,
ChainNetworkTronNile:
return true
default:
return false
}
}
// IsTron returns true if the network is a TRON network.
func (n ChainNetwork) IsTron() bool {
switch n {
case ChainNetworkTronMainnet, ChainNetworkTronNile:
return true
default:
return false
}
}
// IsEVM returns true if the network is an EVM-compatible network.
func (n ChainNetwork) IsEVM() bool {
switch n {
case ChainNetworkArbitrumOne, ChainNetworkArbitrumSepolia, ChainNetworkEthereumMainnet:
return true
default:
return false
}
}
// IsTestnet returns true if the network is a testnet.
func (n ChainNetwork) IsTestnet() bool {
switch n {
case ChainNetworkTronNile, ChainNetworkArbitrumSepolia:
return true
default:
}
return false
}
+33
View File
@@ -0,0 +1,33 @@
package model
import "testing"
func TestChainNetworkArbitrumSepoliaSupport(t *testing.T) {
network, ok := ParseChainNetwork("arbitrum_sepolia")
if !ok {
t.Fatal("ParseChainNetwork(arbitrum_sepolia) returned ok=false")
}
if network != ChainNetworkArbitrumSepolia {
t.Fatalf("ParseChainNetwork(arbitrum_sepolia) = %q, want %q", network, ChainNetworkArbitrumSepolia)
}
if !network.IsValid() {
t.Fatal("ChainNetworkArbitrumSepolia should be valid")
}
if !network.IsEVM() {
t.Fatal("ChainNetworkArbitrumSepolia should be EVM")
}
if !network.IsTestnet() {
t.Fatal("ChainNetworkArbitrumSepolia should be testnet")
}
found := false
for _, candidate := range AllChainNetworks() {
if candidate == ChainNetworkArbitrumSepolia {
found = true
break
}
}
if !found {
t.Fatal("AllChainNetworks() is missing ChainNetworkArbitrumSepolia")
}
}
+29
View File
@@ -0,0 +1,29 @@
package model
import (
"strings"
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
)
// ChainWalletRoute stores authoritative wallet-to-gateway routing metadata.
type ChainWalletRoute struct {
storable.Base `bson:",inline" json:",inline"`
OrganizationRef string `bson:"organizationRef" json:"organizationRef"`
WalletRef string `bson:"walletRef" json:"walletRef"`
Network string `bson:"network" json:"network"`
GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"`
}
func (*ChainWalletRoute) Collection() string {
return mservice.WalletRoutes
}
func (r *ChainWalletRoute) Normalize() {
r.OrganizationRef = strings.TrimSpace(r.OrganizationRef)
r.WalletRef = strings.TrimSpace(r.WalletRef)
r.Network = strings.ToLower(strings.TrimSpace(r.Network))
r.GatewayID = strings.TrimSpace(r.GatewayID)
}
+25
View File
@@ -0,0 +1,25 @@
package model
import (
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Client struct {
storable.Base `bson:",inline" json:",inline"`
ClientID string `bson:"clientId"`
ClientName string `bson:"clientName"`
ClientSecret string `bson:"clientSecret,omitempty"`
AllowedCIDRs []string `bson:"allowedCIDRs,omitempty"`
AllowedScopes []string `bson:"allowedScopes"`
RedirectURIs []string `bson:"redirectURIs"`
GrantTypes []string `bson:"grantTypes"`
TokenEndpointAuthMethod string `bson:"tokenEndpointAuthMethod"`
AccountRef *bson.ObjectID `bson:"accountRef,omitempty"` // owner reference
IsRevoked bool `bson:"isRevoked"`
}
func (*Client) Collection() string {
return mservice.Clients
}
+127
View File
@@ -0,0 +1,127 @@
package model
import (
"encoding/json"
"fmt"
"strings"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
type CryptoAddressPaymentData struct {
Currency Currency `bson:"currency" json:"currency"`
Address string `bson:"address" json:"address"`
Network string `bson:"network" json:"network"`
DestinationTag *string `bson:"destinationTag,omitempty" json:"destinationTag,omitempty"`
}
type cryptoAddressAssetJSON struct {
Chain string `json:"chain,omitempty"`
TokenSymbol string `json:"token_symbol,omitempty"`
ContractAddress string `json:"contract_address,omitempty"`
}
func (d CryptoAddressPaymentData) MarshalJSON() ([]byte, error) {
var payload struct {
Asset *cryptoAddressAssetJSON `json:"asset,omitempty"`
Address string `json:"address"`
Memo *string `json:"memo,omitempty"`
}
payload.Address = strings.TrimSpace(d.Address)
if strings.TrimSpace(d.Network) != "" || strings.TrimSpace(string(d.Currency)) != "" {
payload.Asset = &cryptoAddressAssetJSON{
Chain: strings.TrimSpace(d.Network),
TokenSymbol: strings.TrimSpace(string(d.Currency)),
}
}
if memo := strings.TrimSpace(strPtr(d.DestinationTag)); memo != "" {
payload.Memo = &memo
}
return json.Marshal(payload)
}
func (d *CryptoAddressPaymentData) UnmarshalJSON(data []byte) error {
var raw struct {
Asset *cryptoAddressAssetJSON `json:"asset"`
Address string `json:"address"`
Memo string `json:"memo"`
Currency string `json:"currency"`
Network string `json:"network"`
DestinationTag *string `json:"destinationTag"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
currency := strings.TrimSpace(raw.Currency)
network := strings.TrimSpace(raw.Network)
if raw.Asset != nil {
if assetCurrency := strings.TrimSpace(raw.Asset.TokenSymbol); assetCurrency != "" {
currency = assetCurrency
}
if assetNetwork := strings.TrimSpace(raw.Asset.Chain); assetNetwork != "" {
network = assetNetwork
}
}
d.Currency = Currency(strings.ToUpper(currency))
d.Address = strings.TrimSpace(raw.Address)
d.Network = strings.ToUpper(network)
d.DestinationTag = clonePaymentDataStringPtr(raw.Memo)
if d.DestinationTag == nil && raw.DestinationTag != nil {
d.DestinationTag = clonePaymentDataStringPtr(*raw.DestinationTag)
}
return nil
}
func (d *CryptoAddressPaymentData) UnmarshalBSON(data []byte) error {
var raw map[string]any
if err := bson.Unmarshal(data, &raw); err != nil {
return err
}
currency := firstPaymentDataString(raw, "currency")
network := firstPaymentDataString(raw, "network")
if asset := paymentDataDocument(raw["asset"]); asset != nil {
if assetCurrency := firstPaymentDataString(asset, "token_symbol", "tokenSymbol"); assetCurrency != "" {
currency = assetCurrency
}
if assetNetwork := firstPaymentDataString(asset, "chain"); assetNetwork != "" {
network = assetNetwork
}
}
d.Currency = Currency(strings.ToUpper(strings.TrimSpace(currency)))
d.Address = firstPaymentDataString(raw, "address")
d.Network = strings.ToUpper(strings.TrimSpace(network))
d.DestinationTag = clonePaymentDataStringPtr(firstPaymentDataString(raw, "destinationTag", "destination_tag", "memo"))
return nil
}
func (m *PaymentMethod) AsCryptoAddress() (*CryptoAddressPaymentData, error) {
if m.Type != PaymentTypeCryptoAddress {
return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not cryptoAddress", m.Type), "type")
}
var d CryptoAddressPaymentData
if err := bson.Unmarshal(m.Data, &d); err != nil {
return nil, err
}
return &d, nil
}
func clonePaymentDataStringPtr(value string) *string {
out := strings.TrimSpace(value)
if out == "" {
return nil
}
return &out
}
func strPtr(value *string) string {
if value == nil {
return ""
}
return *value
}
+84
View File
@@ -0,0 +1,84 @@
package model
import (
"encoding/json"
"testing"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestCryptoAddressPaymentDataReadsAssetFromGenericDocument(t *testing.T) {
// The intermediate BSON document produced by PATCH decodes a nested asset
// as bson.D before the payment service record is encoded.
var payload any
if err := json.Unmarshal([]byte(`{"asset":{"chain":"tron_mainnet","token_symbol":"usdt"},"address":"TXabc","memo":"tag-1"}`), &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
raw, err := bson.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
var data CryptoAddressPaymentData
if err := bson.Unmarshal(raw, &data); err != nil {
t.Fatalf("unmarshal crypto data: %v", err)
}
if got, want := string(data.Currency), "USDT"; got != want {
t.Fatalf("currency mismatch: got=%q want=%q", got, want)
}
if got, want := data.Network, "TRON_MAINNET"; got != want {
t.Fatalf("network mismatch: got=%q want=%q", got, want)
}
if got, want := data.Address, "TXabc"; got != want {
t.Fatalf("address mismatch: got=%q want=%q", got, want)
}
if got, want := strPtr(data.DestinationTag), "tag-1"; got != want {
t.Fatalf("destination tag mismatch: got=%q want=%q", got, want)
}
}
func TestCryptoAddressPaymentDataReadsAssetFromMapDocument(t *testing.T) {
raw, err := bson.Marshal(bson.M{
"asset": bson.M{"chain": "arbitrum_one", "tokenSymbol": "usdc"},
"address": "0xabc",
})
if err != nil {
t.Fatalf("marshal crypto data: %v", err)
}
var data CryptoAddressPaymentData
if err := bson.Unmarshal(raw, &data); err != nil {
t.Fatalf("unmarshal crypto data: %v", err)
}
if got, want := string(data.Currency), "USDC"; got != want {
t.Fatalf("currency mismatch: got=%q want=%q", got, want)
}
if got, want := data.Network, "ARBITRUM_ONE"; got != want {
t.Fatalf("network mismatch: got=%q want=%q", got, want)
}
}
func TestCryptoAddressPaymentDataReadsStoredFlatShape(t *testing.T) {
raw, err := bson.Marshal(CryptoAddressPaymentData{
Currency: "USDT",
Address: "TXabc",
Network: "TRON_MAINNET",
})
if err != nil {
t.Fatalf("marshal crypto data: %v", err)
}
var data CryptoAddressPaymentData
if err := bson.Unmarshal(raw, &data); err != nil {
t.Fatalf("unmarshal crypto data: %v", err)
}
if got, want := string(data.Currency), "USDT"; got != want {
t.Fatalf("currency mismatch: got=%q want=%q", got, want)
}
if got, want := data.Network, "TRON_MAINNET"; got != want {
t.Fatalf("network mismatch: got=%q want=%q", got, want)
}
}
+47
View File
@@ -0,0 +1,47 @@
package model
import (
"fmt"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
// TokenPaymentData represents a network or gateway-issued card token.
type TokenPaymentData struct {
Token string `bson:"token" json:"token"`
Network string `bson:"network,omitempty" json:"network,omitempty"`
Last4 string `bson:"last4,omitempty" json:"masked_pan,omitempty"`
ExpMonth *int `bson:"exp_month,omitempty" json:"exp_month,omitempty"`
ExpYear *int `bson:"exp_year,omitempty" json:"exp_year,omitempty"`
CardholderName string `bson:"cardholderName,omitempty" json:"cardholderName,omitempty"`
Country string `bson:"country,omitempty" json:"country,omitempty"`
}
func (d *TokenPaymentData) UnmarshalBSON(data []byte) error {
var raw map[string]any
if err := bson.Unmarshal(data, &raw); err != nil {
return err
}
d.Token = paymentDataString(raw["token"])
d.Network = paymentDataString(raw["network"])
d.Last4 = firstPaymentDataString(raw, "last4", "last_4", "masked_pan")
d.ExpMonth = firstPaymentDataInt(raw, "exp_month", "expMonth")
d.ExpYear = firstPaymentDataInt(raw, "exp_year", "expYear")
d.CardholderName = firstPaymentDataString(raw, "cardholderName", "cardholder_name")
d.Country = paymentDataString(raw["country"])
return nil
}
func (m *PaymentMethod) AsCardToken() (*TokenPaymentData, error) {
if m.Type != PaymentTypeCardToken {
return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not cardToken", m.Type), "type")
}
var d TokenPaymentData
if err := bson.Unmarshal(m.Data, &d); err != nil {
return nil, err
}
return &d, nil
}
+30
View File
@@ -0,0 +1,30 @@
package model
import (
"testing"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestTokenPaymentDataUnmarshalReadsSnakeCaseExpiryAsInt(t *testing.T) {
raw, err := bson.Marshal(bson.M{
"token": "card-token",
"last4": "1234",
"exp_month": "01",
"exp_year": 2031.0,
})
if err != nil {
t.Fatalf("marshal card token data: %v", err)
}
var data TokenPaymentData
if err := bson.Unmarshal(raw, &data); err != nil {
t.Fatalf("unmarshal card token data: %v", err)
}
if got, want := data.ExpMonth, 1; got == nil || *got != want {
t.Fatalf("exp_month mismatch: got=%v want=%d", got, want)
}
if got, want := data.ExpYear, 2031; got == nil || *got != want {
t.Fatalf("exp_year mismatch: got=%v want=%d", got, want)
}
}
+77
View File
@@ -0,0 +1,77 @@
package model
import (
"sort"
"strings"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/moneybase"
)
type Currency string
const (
CurrencyEUR Currency = "EUR" // Euro
CurrencyUSD Currency = "USD" // US Dollar
CurrencyRUB Currency = "RUB" // Russian Ruble
CurrencyUAH Currency = "UAH" // Ukrainian Hryvnia
CurrencyPLN Currency = "PLN" // Polish Złoty
CurrencyCZK Currency = "CZK" // Czech Koruna
CurrencyUSDT Currency = "USDT" // Tether USD
)
const MaxCurrencyScale uint32 = moneybase.MaxScale
func NormalizeCurrency(value string) Currency {
return Currency(strings.ToUpper(strings.TrimSpace(value)))
}
func NormalizeExplicitCurrencyScales(values map[Currency]uint32) (map[Currency]uint32, error) {
if len(values) == 0 {
return nil, merrors.InvalidArgument("currency scales are required")
}
out := make(map[Currency]uint32, len(values))
for rawCurrency, scale := range values {
currency := NormalizeCurrency(string(rawCurrency))
if currency == "" {
return nil, merrors.InvalidArgument("currency scale key is required")
}
if scale > MaxCurrencyScale {
return nil, merrors.InvalidArgumentf("currency scale for %s exceeds %d", currency, MaxCurrencyScale)
}
out[currency] = scale
}
return out, nil
}
func CurrencyScale(scales map[Currency]uint32, currency string) (uint32, error) {
normalized := NormalizeCurrency(currency)
if normalized == "" {
return 0, merrors.InvalidArgument("currency is required")
}
if scales == nil {
return 0, merrors.InvalidArgument("currency scale map is required")
}
scale, ok := scales[normalized]
if !ok {
return 0, merrors.InvalidArgument("currency scale for " + string(normalized) + " is not configured")
}
return scale, nil
}
func CurrencyScaleKeys(scales map[Currency]uint32) []string {
if scales == nil {
return nil
}
keys := make([]string, 0, len(scales))
for currency := range scales {
keys = append(keys, string(currency))
}
sort.Strings(keys)
return keys
}
type Amount struct {
Total float64 `bson:"total" json:"total"` // Total amount billed
Currency Currency `bson:"currency" json:"currency"` // Currency for the invoice
}
+41
View File
@@ -0,0 +1,41 @@
package model
import (
"errors"
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/moneybase"
"github.com/stretchr/testify/require"
)
func TestMaxCurrencyScaleUsesSharedMoneyBaseRange(t *testing.T) {
require.Equal(t, moneybase.MaxScale, MaxCurrencyScale)
}
func TestNormalizeExplicitCurrencyScalesNormalizesKeys(t *testing.T) {
scales, err := NormalizeExplicitCurrencyScales(map[Currency]uint32{"usd": 2, "usdt": 6})
require.NoError(t, err)
usd, err := CurrencyScale(scales, "USD")
require.NoError(t, err)
require.Equal(t, uint32(2), usd)
usdt, err := CurrencyScale(scales, "USDT")
require.NoError(t, err)
require.Equal(t, uint32(6), usdt)
}
func TestCurrencyScaleRejectsUnconfiguredCurrency(t *testing.T) {
_, err := CurrencyScale(map[Currency]uint32{"USD": 2}, "KWD")
require.Error(t, err)
require.True(t, errors.Is(err, merrors.ErrInvalidArg), "err=%v", err)
}
func TestNormalizeExplicitCurrencyScalesRejectsMissingScales(t *testing.T) {
_, err := NormalizeExplicitCurrencyScales(nil)
require.Error(t, err)
require.True(t, errors.Is(err, merrors.ErrInvalidArg), "err=%v", err)
}
+11
View File
@@ -0,0 +1,11 @@
package model
type Describable struct {
Name string `bson:"name" json:"name"` // Name
Description *string `bson:"description,omitempty" json:"description,omitempty"` // Optional description
}
const (
NameField = "name"
DescriptionField = "description"
)
+8
View File
@@ -0,0 +1,8 @@
package model
type SettingsT = map[string]any
type DriverConfig[T any] struct {
Driver T `yaml:"driver"`
Settings SettingsT `yaml:"settings"`
}
+26
View File
@@ -0,0 +1,26 @@
package model
import (
"fmt"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
type IbanPaymentData struct {
Iban string `bson:"iban" json:"iban"`
AccountHolder string `bson:"accountHolder" json:"accountHolder"`
Bic *string `bson:"bic,omitempty" json:"bic,omitempty"`
BankName *string `bson:"bankName,omitempty" json:"bankName,omitempty"`
}
func (m *PaymentMethod) AsIban() (*IbanPaymentData, error) {
if m.Type != PaymentTypeIban {
return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not iban", m.Type), "type")
}
var d IbanPaymentData
if err := bson.Unmarshal(m.Data, &d); err != nil {
return nil, err
}
return &d, nil
}
+12
View File
@@ -0,0 +1,12 @@
package model
import "git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
type Indexable struct {
Index int `bson:"index" json:"index"`
}
type IndexableRef struct {
storable.Ref `bson:",inline" json:",inline"`
Indexable `bson:",inline" json:",inline"`
}
+72
View File
@@ -0,0 +1,72 @@
package model
import (
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/bson"
)
type InvitationStatus string
const (
InvitationCreated InvitationStatus = "created"
InvitationSent InvitationStatus = "sent"
InvitationAccepted InvitationStatus = "accepted"
InvitationDeclined InvitationStatus = "declined"
InvitationRevoked InvitationStatus = "revoked"
)
type invitationDesc struct {
Email string `bson:"email" json:"email"`
Name string `bson:"name" json:"name"`
LastName string `bson:"lastName" json:"lastName"`
Comment string `bson:"comment" json:"comment"`
}
func (id *invitationDesc) Collection() string {
return mservice.Invitations
}
type Invitation struct {
PermissionBound `bson:",inline" json:",inline"`
OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"`
RoleRef bson.ObjectID `bson:"roleRef" json:"roleRef"`
InviterRef bson.ObjectID `bson:"inviterRef" json:"inviterRef"`
Status InvitationStatus `bson:"status" json:"status"`
ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"`
Content invitationDesc `bson:"description" json:"description"`
}
func (*Invitation) Collection() string {
return mservice.Invitations
}
type employeeDesc struct {
Description Describable `bson:"description" json:"description"`
AvatarURL *string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"`
}
type organizationDesc struct {
Description Describable `bson:"description" json:"description"`
LogoURL *string `bson:"logoUrl,omitempty" json:"logoUrl,omitempty"`
}
type invitationStorable struct {
storable.Base `bson:",inline" json:",inline"`
ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"`
}
type PublicInvitation struct {
Storable invitationStorable `bson:"storable" json:"storable"`
Employee employeeDesc `bson:"employee" json:"employee"`
Organization organizationDesc `bson:"organization" json:"organization"`
Role Describable `bson:"role" json:"role"`
Invitation invitationDesc `bson:"invitation" json:"invitation"`
RequiresRegistration bool `bson:"registrationRequired" json:"registrationRequired"`
}
func (pi *PublicInvitation) Collection() string {
return mservice.Invitations
}
+18
View File
@@ -0,0 +1,18 @@
package model
func PendingInvitationStatuses() []InvitationStatus {
return []InvitationStatus{InvitationCreated, InvitationSent}
}
func PendingInvitationStatusValues() []string {
statuses := PendingInvitationStatuses()
values := make([]string, 0, len(statuses))
for _, status := range statuses {
values = append(values, string(status))
}
return values
}
func IsPendingInvitationStatus(status InvitationStatus) bool {
return status == InvitationCreated || status == InvitationSent
}
+62
View File
@@ -0,0 +1,62 @@
package model
import "testing"
func TestPendingInvitationStatusValues(t *testing.T) {
got := PendingInvitationStatusValues()
want := []string{"created", "sent"}
if len(got) != len(want) {
t.Fatalf("pending status count mismatch: got %d want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("pending status %d mismatch: got %q want %q", i, got[i], want[i])
}
}
if !IsPendingInvitationStatus(InvitationCreated) {
t.Fatal("created must be pending")
}
if !IsPendingInvitationStatus(InvitationSent) {
t.Fatal("sent must be pending")
}
for _, status := range []InvitationStatus{InvitationAccepted, InvitationDeclined, InvitationRevoked} {
if IsPendingInvitationStatus(status) {
t.Fatalf("%q must not be pending", status)
}
}
}
func TestInvitationValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
wantErr bool
}{
{name: "valid", email: "invitee@example.com"},
{name: "whitespace padded valid", email: " invitee@example.com "},
{name: "empty", email: "", wantErr: true},
{name: "whitespace only", email: " ", wantErr: true},
{name: "missing domain", email: "invitee@", wantErr: true},
{name: "missing at", email: "invitee.example.com", wantErr: true},
{name: "display name", email: "Invitee <invitee@example.com>", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inv := &Invitation{
Status: InvitationCreated,
Content: invitationDesc{
Email: tt.email,
},
}
err := inv.Validate()
if tt.wantErr && err == nil {
t.Fatal("expected validation error")
}
if !tt.wantErr && err != nil {
t.Fatalf("expected valid invitation, got %v", err)
}
})
}
}
@@ -0,0 +1,23 @@
package model
import (
"net/mail"
"strings"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
)
func (i *Invitation) Validate() error {
if i == nil {
return merrors.InvalidArgument("invitation is required", "invitation")
}
if strings.TrimSpace(i.Content.Email) == "" {
return merrors.InvalidArgument("invitation email must not be empty", "description.email")
}
trimmedEmail := strings.TrimSpace(i.Content.Email)
parsed, err := mail.ParseAddress(trimmedEmail)
if err != nil || parsed.Address != trimmedEmail {
return merrors.InvalidArgument("invitation email is malformed", "description.email")
}
return nil
}
+132
View File
@@ -0,0 +1,132 @@
package model
import (
"strings"
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model/account_role"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/bson"
)
// AccountType defines the category of account (asset, liability, revenue, expense).
type LedgerAccountType string
const (
LedgerAccountTypeAsset LedgerAccountType = "asset"
LedgerAccountTypeLiability LedgerAccountType = "liability"
LedgerAccountTypeRevenue LedgerAccountType = "revenue"
LedgerAccountTypeExpense LedgerAccountType = "expense"
)
// AccountStatus tracks the operational state of an account.
type LedgerAccountStatus string
const (
LedgerAccountStatusActive LedgerAccountStatus = "active"
LedgerAccountStatusFrozen LedgerAccountStatus = "frozen"
LedgerAccountStatusClosed LedgerAccountStatus = "closed"
)
type LedgerAccountScope string
const (
LedgerAccountScopeOrganization LedgerAccountScope = "organization"
LedgerAccountScopeSystem LedgerAccountScope = "system"
)
type SystemAccountPurpose string
const (
SystemAccountPurposeExternalSource SystemAccountPurpose = "external_source"
SystemAccountPurposeExternalSink SystemAccountPurpose = "external_sink"
)
// Account represents a ledger account that holds balances for a specific currency.
type LedgerAccount struct {
storable.Base `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
// Scope defines whether the account belongs to an organization
// or is a system-level ledger account used for internal accounting.
Scope LedgerAccountScope `bson:"scope" json:"scope"`
// SystemPurpose specifies the role of a system-scoped account
// (e.g., external source or sink of funds). Must be set for system accounts
// and must be nil for organization accounts.
SystemPurpose *SystemAccountPurpose `bson:"systemPurpose,omitempty" json:"systemPurpose,omitempty"`
// OrganizationRef links the account to an organization.
// Must be set for organization accounts and nil for system accounts.
OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
// Role defines the functional purpose of the account within an organization
// (e.g., pending, operating, settlement, hold, etc.).
// Must be set for organization accounts and omitted for system accounts.
Role account_role.AccountRole `bson:"role,omitempty" json:"role,omitempty"`
// AccountCode is a logical classification code of the account
// (e.g., "asset:cash:usd") used for reporting and grouping.
AccountCode string `bson:"accountCode" json:"accountCode"`
// Currency is the ISO 4217 currency code the account operates in.
Currency string `bson:"currency" json:"currency"`
// AccountType defines the accounting category of the account
// (asset, liability, revenue, expense).
AccountType LedgerAccountType `bson:"accountType" json:"accountType"`
// Status represents the operational state of the account.
Status LedgerAccountStatus `bson:"status" json:"status"`
// AllowNegative defines whether the account is allowed to have
// a negative balance (used for system control accounts).
AllowNegative bool `bson:"allowNegative" json:"allowNegative"`
// OwnerRef optionally links the account to a specific owner entity
// (e.g., user or sub-entity within the organization).
OwnerRef *bson.ObjectID `bson:"ownerRef,omitempty" json:"ownerRef,omitempty"`
// Metadata holds additional arbitrary key-value attributes.
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
}
// Collection implements storable.Storable.
func (*LedgerAccount) Collection() string {
return mservice.LedgerAccounts
}
// Validate enforces scope-specific invariants for ledger accounts.
func (a *LedgerAccount) Validate() error {
if a == nil {
return merrors.InvalidArgument("ledger account is required")
}
switch a.Scope {
case LedgerAccountScopeOrganization:
if a.OrganizationRef == nil || a.OrganizationRef.IsZero() {
return merrors.InvalidArgument("organization_ref is required for organization accounts")
}
if strings.TrimSpace(string(a.Role)) == "" {
return merrors.InvalidArgument("role is required for organization accounts")
}
if a.SystemPurpose != nil {
return merrors.InvalidArgument("system_purpose must be nil for organization accounts")
}
case LedgerAccountScopeSystem:
if a.OrganizationRef != nil && !a.OrganizationRef.IsZero() {
return merrors.InvalidArgument("organization_ref must be nil for system accounts")
}
if strings.TrimSpace(string(a.Role)) != "" {
return merrors.InvalidArgument("role must be empty for system accounts")
}
if a.SystemPurpose == nil {
return merrors.InvalidArgument("system_purpose is required for system accounts")
}
default:
return merrors.InvalidArgument("scope is required")
}
return nil
}
+20
View File
@@ -0,0 +1,20 @@
package model
import (
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/bson"
)
type TypeBound struct {
Type mservice.Type `bson:"type" json:"type"`
}
type ObjectRefs struct {
TypeBound `bson:"inline" json:"inline"`
Refs []bson.ObjectID `bson:"refs,omitempty" json:"refs,omitempty"`
}
type ObjectRef struct {
TypeBound `bson:"inline" json:"inline"`
Ref bson.ObjectID `bson:"ref,omitempty" json:"ref,omitempty"`
}
+111
View File
@@ -0,0 +1,111 @@
package model
import (
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/bson"
)
type OrganizationSignupState string
const (
OrganizationSignupStatePending OrganizationSignupState = "pending_signup_provisioning"
OrganizationSignupStateBlocked OrganizationSignupState = "blocked_signup_provisioning"
OrganizationSignupStateActive OrganizationSignupState = "active"
)
type OrganizationBase struct {
PermissionBound `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
TenantRef bson.ObjectID `bson:"tenantRef" json:"tenantRef"`
TimeZone string `bson:"timeZone" json:"timeZone"`
LogoURL *string `bson:"logoUrl,omitempty" json:"logoUrl,omitempty"`
SignupState OrganizationSignupState `bson:"signupState,omitempty" json:"signupState,omitempty"`
}
func (*OrganizationBase) Collection() string {
return mservice.Organizations
}
type Organization struct {
OrganizationBase `bson:",inline" json:",inline"`
Members []bson.ObjectID `bson:"members" json:"members"`
Provisioning *OrganizationProvisioning `bson:"provisioning,omitempty" json:"-"`
}
// OrganizationProvisioning tracks an organization that is being provisioned and
// has no members or roles yet. It is the only link between such an organization
// and its future owner, and it also guards concurrent provisioning attempts.
// Activation clears only the lease fields: the provenance stays so a retried
// create can recognize the organization it already activated.
//
// It lives on Organization rather than OrganizationBase on purpose: update
// paths replace OrganizationBase wholesale from client payloads, which would
// otherwise wipe it. It is never exposed over the API.
type OrganizationProvisioning struct {
OwnerRef bson.ObjectID `bson:"ownerRef" json:"-"`
// Name snapshots the organization name of the first attempt. It is the
// immutable half of the dedup key: renaming the organization must not
// detach it from retries of the create that produced it.
Name string `bson:"name,omitempty" json:"-"`
Origin string `bson:"origin" json:"-"`
ActorRef string `bson:"actorRef,omitempty" json:"-"`
StartedAt time.Time `bson:"startedAt" json:"-"`
LeaseOwner string `bson:"leaseOwner,omitempty" json:"-"`
LeaseExpiresAt time.Time `bson:"leaseExpiresAt,omitempty" json:"-"`
}
const (
OrganizationSignupStateField = "signupState"
OrganizationProvisioningField = "provisioning"
OrganizationProvisioningOwnerRefField = "provisioning.ownerRef"
OrganizationProvisioningNameField = "provisioning.name"
OrganizationProvisioningLeaseOwnerField = "provisioning.leaseOwner"
OrganizationProvisioningLeaseField = "provisioning.leaseExpiresAt"
)
// LeaseHeld reports whether another attempt still owns the provisioning lease.
func (p *OrganizationProvisioning) LeaseHeld(now time.Time) bool {
return p != nil && p.LeaseExpiresAt.After(now)
}
// CanFinishProvisioning reports whether an operator can drive this organization
// to active through the backoffice create operation: only pending organizations
// stamped by operator provisioning are resumable. Signup-era organizations
// carry no provisioning stamp.
func (o *Organization) CanFinishProvisioning() bool {
return o != nil && o.Provisioning != nil && o.SignupState == OrganizationSignupStatePending
}
func (o *Organization) NormalizeSignupState() {
if o == nil {
return
}
switch o.SignupState {
case OrganizationSignupStatePending, OrganizationSignupStateBlocked, OrganizationSignupStateActive:
default:
o.SignupState = OrganizationSignupStateActive
}
}
type OrganizationBound interface {
GetOrganizationRef() bson.ObjectID
SetOrganizationRef(organizationRef bson.ObjectID)
}
const (
OrganizationRefField = "organizationRef"
)
type OrganizationBoundBase struct {
OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"`
}
func (a *OrganizationBoundBase) GetOrganizationRef() bson.ObjectID {
return a.OrganizationRef
}
func (a *OrganizationBoundBase) SetOrganizationRef(organizationRef bson.ObjectID) {
a.OrganizationRef = organizationRef
}
+21
View File
@@ -0,0 +1,21 @@
package model
import (
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"go.mongodb.org/mongo-driver/v2/bson"
)
// OwnerBound is implemented by resources that follow the personal-vs-org
// ownership model (ownerKind + ownerRef). It is the type constraint for
// ScopedDB and the canonical contract for scoped-access resources.
//
// permissionRef is deliberately absent: it is only meaningful for org-owned
// resources and is supplied separately (as OrgPolicyRef) to the enforcement
// layer. Mixing it here would re-introduce the very coupling this interface
// is meant to eliminate.
type OwnerBound interface {
storable.Storable
OrganizationBound
GetOwnerKind() OwnerKind
GetOwnerRef() bson.ObjectID
}
+24
View File
@@ -0,0 +1,24 @@
package model
// OwnerKind describes who owns a scopable resource (ledger account, recipient,
// payment method, …). It is the single source of truth for the personal-vs-org
// access model: a resource is either owned by an individual account (personal)
// or by an organization (org-scoped).
//
// The string values are persisted in Mongo and used in policy checks, so they
// must stay stable. Scopable resources embed model.Owner (which carries an
// OwnerKind) to keep one canonical vocabulary across the codebase.
type OwnerKind string
const (
// OwnerKindAccount marks a personal resource. Access is proven by ownership
// alone: caller is allowed iff ownerRef == caller's account. No policy check.
OwnerKindAccount OwnerKind = "account"
// OwnerKindOrganization marks an org-scoped resource. Access is gated by the
// relevant org policy via the Enforcer.
OwnerKindOrganization OwnerKind = "organization"
)
func (k OwnerKind) IsValid() bool {
return k == OwnerKindAccount || k == OwnerKindOrganization
}
+276
View File
@@ -0,0 +1,276 @@
package model
import (
"bytes"
"encoding/json"
"fmt"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
type PaymentType int
const (
PaymentTypeIban PaymentType = iota
PaymentTypeCard
PaymentTypeCardToken
PaymentTypeBankAccount
PaymentTypeWallet
PaymentTypeCryptoAddress
PaymentTypeLedger
)
var paymentTypeToString = map[PaymentType]string{
PaymentTypeIban: "iban",
PaymentTypeCard: "card",
PaymentTypeCardToken: "cardToken",
PaymentTypeBankAccount: "bankAccount",
PaymentTypeWallet: "wallet",
PaymentTypeCryptoAddress: "cryptoAddress",
PaymentTypeLedger: "ledger",
}
var paymentTypeFromString = map[string]PaymentType{
"iban": PaymentTypeIban,
"card": PaymentTypeCard,
"cardToken": PaymentTypeCardToken,
"bankAccount": PaymentTypeBankAccount,
"wallet": PaymentTypeWallet,
"cryptoAddress": PaymentTypeCryptoAddress,
"ledger": PaymentTypeLedger,
}
func (t PaymentType) String() string {
if v, ok := paymentTypeToString[t]; ok {
return v
}
return "iban"
}
func (t PaymentType) IsValid() bool {
_, ok := paymentTypeToString[t]
return ok
}
func (t PaymentType) MarshalJSON() ([]byte, error) {
return json.Marshal(t.String())
}
func (t *PaymentType) UnmarshalJSON(data []byte) error {
var val string
if err := json.Unmarshal(data, &val); err != nil {
return err
}
v, ok := paymentTypeFromString[val]
if !ok {
return merrors.InvalidArgument(fmt.Sprintf("unknown PaymentType: %q", val))
}
*t = v
return nil
}
type PaymentMethodOwnerKind string
const (
PaymentMethodOwnerKindUnspecified PaymentMethodOwnerKind = ""
PaymentMethodOwnerKindAccount PaymentMethodOwnerKind = "ACCOUNT"
PaymentMethodOwnerKindRecipient PaymentMethodOwnerKind = "RECIPIENT"
)
func (k PaymentMethodOwnerKind) String() string {
return string(k)
}
func (k PaymentMethodOwnerKind) IsValid() bool {
switch k {
case PaymentMethodOwnerKindAccount, PaymentMethodOwnerKindRecipient:
return true
default:
return false
}
}
type PaymentMethod struct {
PermissionBound `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
OwnerKind PaymentMethodOwnerKind `bson:"ownerKind" json:"ownerKind"`
OwnerRef bson.ObjectID `bson:"ownerRef" json:"ownerRef"`
Type PaymentType `bson:"type" json:"type"`
Data bson.Raw `bson:"data" json:"data"`
IsMain bool `bson:"isMain" json:"isMain"`
}
type paymentMethodJSON struct {
PermissionBound `json:",inline"`
Describable `json:",inline"`
OwnerKind PaymentMethodOwnerKind `json:"ownerKind"`
OwnerRef bson.ObjectID `json:"ownerRef"`
Type PaymentType `json:"type"`
Data json.RawMessage `json:"data"`
IsMain bool `json:"isMain"`
}
func (m PaymentMethod) MarshalJSON() ([]byte, error) {
marshaledData, err := m.marshalDataJSON()
if err != nil {
return nil, err
}
payload := paymentMethodJSON{
PermissionBound: m.PermissionBound,
Describable: m.Describable,
OwnerKind: m.OwnerKind,
OwnerRef: m.OwnerRef,
Type: m.Type,
Data: marshaledData,
IsMain: m.IsMain,
}
return json.Marshal(payload)
}
func (m *PaymentMethod) UnmarshalJSON(data []byte) error {
var payload paymentMethodJSON
if err := json.Unmarshal(data, &payload); err != nil {
return err
}
m.PermissionBound = payload.PermissionBound
m.Describable = payload.Describable
m.OwnerKind = payload.OwnerKind
m.OwnerRef = payload.OwnerRef
m.Type = payload.Type
m.IsMain = payload.IsMain
if len(payload.Data) == 0 || bytes.Equal(payload.Data, []byte("null")) {
m.Data = nil
return nil
}
if raw, ok, err := m.unmarshalTypedDataJSON(payload.Data); ok || err != nil {
if err != nil {
return err
}
m.Data = raw
return nil
}
var rawData map[string]any
if err := json.Unmarshal(payload.Data, &rawData); err != nil {
return err
}
raw, err := bson.Marshal(rawData)
if err != nil {
return err
}
m.Data = raw
return nil
}
func (m PaymentMethod) marshalDataJSON() (json.RawMessage, error) {
if len(m.Data) == 0 {
return nil, nil
}
var payload any
var err error
switch m.Type {
case PaymentTypeIban:
payload, err = marshalTypedPaymentData[IbanPaymentData](m.Data)
case PaymentTypeCard:
payload, err = marshalTypedPaymentData[CardPaymentData](m.Data)
case PaymentTypeCardToken:
payload, err = marshalTypedPaymentData[TokenPaymentData](m.Data)
case PaymentTypeBankAccount:
payload, err = marshalTypedPaymentData[RussianBankAccountPaymentData](m.Data)
case PaymentTypeWallet:
payload, err = marshalTypedPaymentData[WalletPaymentData](m.Data)
case PaymentTypeCryptoAddress:
payload, err = marshalTypedPaymentData[CryptoAddressPaymentData](m.Data)
case PaymentTypeLedger:
payload, err = marshalTypedPaymentData[LedgerPaymentData](m.Data)
default:
var data bson.M
if err := bson.Unmarshal(m.Data, &data); err != nil {
return nil, err
}
if data == nil {
return nil, nil
}
payload = data
}
if err != nil {
return nil, err
}
data, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return data, nil
}
func (m PaymentMethod) unmarshalTypedDataJSON(data json.RawMessage) (bson.Raw, bool, error) {
switch m.Type {
case PaymentTypeIban:
return unmarshalTypedPaymentData[IbanPaymentData](data)
case PaymentTypeCard:
return unmarshalTypedPaymentData[CardPaymentData](data)
case PaymentTypeCardToken:
return unmarshalTypedPaymentData[TokenPaymentData](data)
case PaymentTypeBankAccount:
return unmarshalTypedPaymentData[RussianBankAccountPaymentData](data)
case PaymentTypeWallet:
return unmarshalTypedPaymentData[WalletPaymentData](data)
case PaymentTypeCryptoAddress:
return unmarshalTypedPaymentData[CryptoAddressPaymentData](data)
case PaymentTypeLedger:
return unmarshalTypedPaymentData[LedgerPaymentData](data)
default:
return nil, false, nil
}
}
func marshalTypedPaymentData[T any](raw bson.Raw) (T, error) {
var payload T
err := bson.Unmarshal(raw, &payload)
return payload, err
}
func unmarshalTypedPaymentData[T any](data json.RawMessage) (bson.Raw, bool, error) {
var payload T
if err := json.Unmarshal(data, &payload); err != nil {
return nil, true, err
}
raw, err := bson.Marshal(payload)
if err != nil {
return nil, true, err
}
return raw, true, nil
}
type PaymentStatus string
const (
PaymentStatusUnspecified PaymentStatus = "unspecified"
// Intent exists, no funds touched yet.
PaymentStatusCreated PaymentStatus = "created"
// Internal Sendico operations: holds, ledger, FX, fee calc.
PaymentStatusAuthorizing PaymentStatus = "authorizing"
// Funds are outside Sendico (rail/gateway/blockchain/provider).
// Observe lives ONLY in this state.
PaymentStatusExecuting PaymentStatus = "executing"
// Final states.
PaymentStatusSucceeded PaymentStatus = "success"
PaymentStatusFailed PaymentStatus = "failed"
PaymentStatusCancelled PaymentStatus = "cancelled"
)
+77
View File
@@ -0,0 +1,77 @@
package model
import (
"encoding/json"
"strings"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
type LedgerPaymentData struct {
AccountRef string `bson:"accountRef,omitempty" json:"account_ref,omitempty"`
AccountCode string `bson:"accountCode,omitempty" json:"account_code,omitempty"`
ContraAccountRef string `bson:"contraAccountRef,omitempty" json:"contra_account_ref,omitempty"`
ContraAccountCode string `bson:"contraAccountCode,omitempty" json:"contra_account_code,omitempty"`
}
func (d *LedgerPaymentData) UnmarshalJSON(data []byte) error {
var raw struct {
AccountRef string `json:"account_ref"`
LedgerAccountRef string `json:"ledger_account_ref"`
AccountCode string `json:"account_code"`
ContraAccountRef string `json:"contra_account_ref"`
ContraLedgerAccountRef string `json:"contra_ledger_account_ref"`
ContraAccountCode string `json:"contra_account_code"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
accountRef := strings.TrimSpace(raw.AccountRef)
ledgerAccountRef := strings.TrimSpace(raw.LedgerAccountRef)
if ledgerAccountRef != "" {
if accountRef != "" && accountRef != ledgerAccountRef {
return merrors.InvalidArgument("account_ref and ledger_account_ref must match")
}
accountRef = ledgerAccountRef
}
contraAccountRef := strings.TrimSpace(raw.ContraAccountRef)
contraLedgerAccountRef := strings.TrimSpace(raw.ContraLedgerAccountRef)
if contraLedgerAccountRef != "" {
if contraAccountRef != "" && contraAccountRef != contraLedgerAccountRef {
return merrors.InvalidArgument("contra_account_ref and contra_ledger_account_ref must match")
}
contraAccountRef = contraLedgerAccountRef
}
d.AccountRef = accountRef
d.AccountCode = strings.TrimSpace(raw.AccountCode)
d.ContraAccountRef = contraAccountRef
d.ContraAccountCode = strings.TrimSpace(raw.ContraAccountCode)
return nil
}
func (d *LedgerPaymentData) UnmarshalBSON(data []byte) error {
var raw map[string]any
if err := bson.Unmarshal(data, &raw); err != nil {
return err
}
d.AccountRef = firstPaymentDataString(raw, "accountRef", "account_ref", "ledgerAccountRef", "ledger_account_ref")
d.AccountCode = firstPaymentDataString(raw, "accountCode", "account_code")
d.ContraAccountRef = firstPaymentDataString(raw, "contraAccountRef", "contra_account_ref", "contraLedgerAccountRef", "contra_ledger_account_ref")
d.ContraAccountCode = firstPaymentDataString(raw, "contraAccountCode", "contra_account_code")
return nil
}
func (m *PaymentMethod) AsLedger() (*LedgerPaymentData, error) {
if m.Type != PaymentTypeLedger {
return nil, merrors.InvalidArgument("payment method type is "+m.Type.String()+", not ledger", "type")
}
var d LedgerPaymentData
if err := bson.Unmarshal(m.Data, &d); err != nil {
return nil, err
}
return &d, nil
}
@@ -0,0 +1,122 @@
package model
import (
"encoding/json"
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/person"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestPaymentMethodMarshalRussianBankAccountUsesJSONRecipientNames(t *testing.T) {
raw, err := bson.Marshal(RussianBankAccountPaymentData{
Recipient: person.Name{
FirstName: "Success",
LastName: "Success",
},
Inn: "7707083893",
Kpp: "770701001",
BankName: "AsterBank Test Bank",
Bik: "044525225",
AccountNumber: "40702810000000080001",
CorrespondentAccount: "30101810400000000225",
})
if err != nil {
t.Fatalf("marshal bank account data: %v", err)
}
payload, err := json.Marshal(PaymentMethod{
Type: PaymentTypeBankAccount,
Data: raw,
})
if err != nil {
t.Fatalf("marshal payment method: %v", err)
}
var decoded struct {
Type PaymentType `json:"type"`
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if decoded.Type != PaymentTypeBankAccount {
t.Fatalf("type mismatch: got=%s want=%s", decoded.Type, PaymentTypeBankAccount)
}
recipient, ok := decoded.Data["recipient"].(map[string]any)
if !ok {
t.Fatalf("recipient is not an object: %#v", decoded.Data["recipient"])
}
if got, want := recipient["first_name"], "Success"; got != want {
t.Fatalf("recipient.first_name mismatch: got=%#v want=%q", got, want)
}
if got, want := recipient["last_name"], "Success"; got != want {
t.Fatalf("recipient.last_name mismatch: got=%#v want=%q", got, want)
}
if _, ok := recipient["firstName"]; ok {
t.Fatalf("recipient must not expose BSON firstName: %#v", recipient)
}
if _, ok := recipient["lastName"]; ok {
t.Fatalf("recipient must not expose BSON lastName: %#v", recipient)
}
}
func TestPaymentMethodUnmarshalRussianBankAccountStoresBSONRecipientNames(t *testing.T) {
payload := []byte(`{
"type": "bankAccount",
"data": {
"recipient": {
"first_name": "Success",
"last_name": "Success"
},
"inn": "7707083893",
"kpp": "770701001",
"bankName": "AsterBank Test Bank",
"bik": "044525225",
"accountNumber": "40702810000000080001",
"correspondentAccount": "30101810400000000225"
}
}`)
var method PaymentMethod
if err := json.Unmarshal(payload, &method); err != nil {
t.Fatalf("unmarshal payment method: %v", err)
}
var stored bson.M
if err := bson.Unmarshal(method.Data, &stored); err != nil {
t.Fatalf("unmarshal stored data: %v", err)
}
recipient, ok := bsonDocumentMap(stored["recipient"])
if !ok {
t.Fatalf("stored recipient is not an object: %#v", stored["recipient"])
}
if got, want := recipient["firstName"], "Success"; got != want {
t.Fatalf("stored recipient.firstName mismatch: got=%#v want=%q", got, want)
}
if got, want := recipient["lastName"], "Success"; got != want {
t.Fatalf("stored recipient.lastName mismatch: got=%#v want=%q", got, want)
}
if _, ok := recipient["first_name"]; ok {
t.Fatalf("stored recipient must not use JSON first_name: %#v", recipient)
}
if _, ok := recipient["last_name"]; ok {
t.Fatalf("stored recipient must not use JSON last_name: %#v", recipient)
}
}
func bsonDocumentMap(value any) (map[string]any, bool) {
switch typed := value.(type) {
case bson.M:
return typed, true
case bson.D:
out := make(map[string]any, len(typed))
for _, element := range typed {
out[element.Key] = element.Value
}
return out, true
default:
return nil, false
}
}
+33
View File
@@ -0,0 +1,33 @@
package model
import (
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"go.mongodb.org/mongo-driver/v2/bson"
)
type PermissionBoundStorable interface {
storable.Storable
OrganizationBound
Archivable
GetPermissionRef() bson.ObjectID
SetPermissionRef(permissionRef bson.ObjectID)
}
type PermissionBound struct {
storable.Base `bson:",inline" json:",inline"`
ArchivableBase `bson:",inline" json:",inline"`
OrganizationBoundBase `bson:",inline" json:",inline"`
PermissionRef bson.ObjectID `bson:"permissionRef" json:"permissionRef"`
}
func (b *PermissionBound) GetPermissionRef() bson.ObjectID {
return b.PermissionRef
}
func (b *PermissionBound) GetOrganizationRef() bson.ObjectID {
return b.OrganizationRef
}
func (b *PermissionBound) SetPermissionRef(permissionRef bson.ObjectID) {
b.PermissionRef = permissionRef
}
+52
View File
@@ -0,0 +1,52 @@
package model
import (
"fmt"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/person"
"go.mongodb.org/mongo-driver/v2/bson"
)
type RussianBankAccountPaymentData struct {
Recipient person.Name `bson:"recipient" json:"recipient"`
Inn string `bson:"inn" json:"inn"`
Kpp string `bson:"kpp" json:"kpp"`
BankName string `bson:"bankName" json:"bankName"`
Bik string `bson:"bik" json:"bik"`
AccountNumber string `bson:"accountNumber" json:"accountNumber"`
CorrespondentAccount string `bson:"correspondentAccount" json:"correspondentAccount"`
}
// UnmarshalBSON accepts both stored recipient names and the API names in the
// intermediate document built by a payment-method PATCH.
func (d *RussianBankAccountPaymentData) UnmarshalBSON(data []byte) error {
type stored RussianBankAccountPaymentData
var decoded stored
if err := bson.Unmarshal(data, &decoded); err != nil {
return err
}
var fields struct {
Recipient map[string]any `bson:"recipient"`
}
if err := bson.Unmarshal(data, &fields); err != nil {
return err
}
decoded.Recipient.FirstName = firstPaymentDataString(fields.Recipient, "firstName", "first_name")
decoded.Recipient.LastName = firstPaymentDataString(fields.Recipient, "lastName", "last_name")
*d = RussianBankAccountPaymentData(decoded)
return nil
}
func (m *PaymentMethod) AsRussianBankAccount() (*RussianBankAccountPaymentData, error) {
if m.Type != PaymentTypeBankAccount {
return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not bankAccount", m.Type), "type")
}
var d RussianBankAccountPaymentData
if err := bson.Unmarshal(m.Data, &d); err != nil {
return nil, err
}
return &d, nil
}
+128
View File
@@ -0,0 +1,128 @@
package model
import (
"encoding/json"
"fmt"
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
type RecipientStatus int
const (
RecipientStatusReady RecipientStatus = iota
RecipientStatusRegistered
RecipientStatusNotRegistered
)
var recipientStatusToString = map[RecipientStatus]string{
RecipientStatusReady: "ready",
RecipientStatusRegistered: "registered",
RecipientStatusNotRegistered: "notRegistered",
}
var recipientStatusFromString = map[string]RecipientStatus{
"ready": RecipientStatusReady,
"registered": RecipientStatusRegistered,
"notRegistered": RecipientStatusNotRegistered,
}
func (s RecipientStatus) String() string {
if v, ok := recipientStatusToString[s]; ok {
return v
}
return "ready" // дефолт, можно поменять
}
// JSON: храним как строку ("ready" / "registered" / "notRegistered")
func (s RecipientStatus) MarshalJSON() ([]byte, error) {
return json.Marshal(s.String())
}
func (s *RecipientStatus) UnmarshalJSON(data []byte) error {
var val string
if err := json.Unmarshal(data, &val); err != nil {
return err
}
v, ok := recipientStatusFromString[val]
if !ok {
return merrors.InvalidArgument(fmt.Sprintf("unknown RecipientStatus: %q", val))
}
*s = v
return nil
}
// RecipientType { internal, external }
type RecipientType int
const (
RecipientTypeInternal RecipientType = iota
RecipientTypeExternal
)
var recipientTypeToString = map[RecipientType]string{
RecipientTypeInternal: "internal",
RecipientTypeExternal: "external",
}
var recipientTypeFromString = map[string]RecipientType{
"internal": RecipientTypeInternal,
"external": RecipientTypeExternal,
}
func (t RecipientType) String() string {
if v, ok := recipientTypeToString[t]; ok {
return v
}
return "internal"
}
func (t RecipientType) MarshalJSON() ([]byte, error) {
return json.Marshal(t.String())
}
func (t *RecipientType) UnmarshalJSON(data []byte) error {
var val string
if err := json.Unmarshal(data, &val); err != nil {
return err
}
v, ok := recipientTypeFromString[val]
if !ok {
return merrors.InvalidArgument(fmt.Sprintf("unknown RecipientType: %q", val))
}
*t = v
return nil
}
// Owner is the ownership token stored under the "owner" BSON/JSON key.
// storable.Ref must stay embedded: a named field would keep the bson ",inline"
// but encoding/json has no inline option and would nest the ref under "Ref",
// while embedding promotes it, so both wire paths are owner.ref and owner.kind.
type Owner struct {
storable.Ref `bson:",inline" json:",inline"`
Kind OwnerKind `bson:"kind" json:"kind"`
}
type Recipient struct {
PermissionBound `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
Email string `bson:"email" json:"email"`
AvatarURL *string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"`
Status RecipientStatus `bson:"status" json:"status"`
Type RecipientType `bson:"type" json:"type"`
Owner Owner `bson:"owner" json:"owner"`
// INN is the recipient's taxpayer identifier. It is the canonical source of
// the INN for payouts to this recipient (flows into customer.tax_identifier),
// replacing the per-payment-method inn field for recipient-owned methods.
INN string `bson:"inn,omitempty" json:"inn,omitempty"`
}
func (r *Recipient) GetOwnerKind() OwnerKind { return r.Owner.Kind }
func (r *Recipient) GetOwnerRef() bson.ObjectID { return r.Owner.Ref.Ref }
func (r *Recipient) SetOwnerKind(k OwnerKind) { r.Owner.Kind = k }
func (r *Recipient) SetOwnerRef(ref bson.ObjectID) { r.Owner.Ref.Ref = ref }
+62
View File
@@ -0,0 +1,62 @@
package model
import (
"encoding/json"
"testing"
"go.mongodb.org/mongo-driver/v2/bson"
)
// TestOwnerJSONFlattensRef locks in the on-wire shape documented on Owner:
// owner.ref and owner.kind must both be top-level keys. Ref must stay
// anonymously embedded — a NAMED field with a `json:",inline"` tag is a
// BSON-only convention and is not flattened by encoding/json, which would
// silently nest it under the Go field name instead (a real regression this
// test caught: {"Ref":{"ref":"..."},"kind":"..."} instead of
// {"ref":"...","kind":"..."}), breaking every JSON consumer of owner.ref.
func TestOwnerJSONFlattensRef(t *testing.T) {
ownerRef := bson.NewObjectID()
owner := Owner{Kind: OwnerKindAccount}
owner.Ref.Ref = ownerRef
b, err := json.Marshal(owner)
if err != nil {
t.Fatalf("marshal owner: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatalf("unmarshal owner json: %v", err)
}
if got, want := decoded["ref"], ownerRef.Hex(); got != want {
t.Fatalf("top-level owner.ref = %v, want %q (got JSON: %s)", got, want, b)
}
if got, want := decoded["kind"], string(OwnerKindAccount); got != want {
t.Fatalf("top-level owner.kind = %v, want %q (got JSON: %s)", got, want, b)
}
if _, nested := decoded["Ref"]; nested {
t.Fatalf("owner.ref must not nest under the Go field name \"Ref\", got JSON: %s", b)
}
}
func TestRecipientJSONRoundTripsOwner(t *testing.T) {
r := &Recipient{}
r.SetOwnerKind(OwnerKindOrganization)
r.SetOwnerRef(bson.NewObjectID())
b, err := json.Marshal(r)
if err != nil {
t.Fatalf("marshal recipient: %v", err)
}
var roundTripped Recipient
if err := json.Unmarshal(b, &roundTripped); err != nil {
t.Fatalf("unmarshal recipient json: %v", err)
}
if roundTripped.GetOwnerKind() != r.GetOwnerKind() {
t.Fatalf("owner kind round-trip = %v, want %v", roundTripped.GetOwnerKind(), r.GetOwnerKind())
}
if roundTripped.GetOwnerRef() != r.GetOwnerRef() {
t.Fatalf("owner ref round-trip = %v, want %v", roundTripped.GetOwnerRef(), r.GetOwnerRef())
}
}
+64
View File
@@ -0,0 +1,64 @@
package model
import (
"encoding/json"
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"go.mongodb.org/mongo-driver/v2/bson"
)
// The frontend parses owner as {"ref": ..., "kind": ...}; a regression to the
// nested {"Ref": {"ref": ...}} shape breaks every recipients screen.
func TestOwnerJSONIsFlat(t *testing.T) {
ref, err := bson.ObjectIDFromHex("69834f22fff13a7457793b6c")
if err != nil {
t.Fatal(err)
}
out, err := json.Marshal(Owner{
Ref: storable.Ref{Ref: ref},
Kind: "account",
})
if err != nil {
t.Fatal(err)
}
want := `{"ref":"69834f22fff13a7457793b6c","kind":"account"}`
if string(out) != want {
t.Fatalf("owner JSON = %s, want %s", out, want)
}
var back Owner
if err := json.Unmarshal(out, &back); err != nil {
t.Fatal(err)
}
if back.Ref.Ref != ref || back.Kind != "account" {
t.Fatalf("round-trip lost data: %+v", back)
}
}
// Mongo queries filter on owner.ref / owner.kind (see auth.ScopedDBImp), so
// the BSON document must keep those exact paths.
func TestOwnerBSONPathsUnchanged(t *testing.T) {
ref, err := bson.ObjectIDFromHex("69834f22fff13a7457793b6c")
if err != nil {
t.Fatal(err)
}
out, err := bson.Marshal(Owner{
Ref: storable.Ref{Ref: ref},
Kind: "account",
})
if err != nil {
t.Fatal(err)
}
var doc bson.M
if err := bson.Unmarshal(out, &doc); err != nil {
t.Fatal(err)
}
if doc["ref"] != ref || doc["kind"] != "account" {
t.Fatalf("bson doc = %v, want flat ref/kind", doc)
}
}
+29
View File
@@ -0,0 +1,29 @@
package model
import (
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/bson"
)
type ClientRefreshToken struct {
SessionIdentifier `bson:",inline" json:",inline"`
RefreshToken string `bson:"token" json:"token"`
}
type RefreshToken struct {
storable.Base `bson:",inline" json:",inline"`
ClientRefreshToken `bson:",inline" json:",inline"`
AccountRef *bson.ObjectID `bson:"accountRef,omitempty" json:"accountRef,omitempty"`
ExpiresAt time.Time `bson:"expiresAt"`
IsRevoked bool `bson:"isRevoked"`
LastUsedAt time.Time `bson:"lastUsedAt,omitempty"`
UserAgent string `bson:"userAgent"`
IPAddress string `bson:"ipAddress"`
}
func (*RefreshToken) Collection() string {
return mservice.RefreshTokens
}
+19
View File
@@ -0,0 +1,19 @@
package model
import "go.mongodb.org/mongo-driver/v2/bson"
// ScopedStorable is the type constraint for resources that follow the
// personal-vs-org dual-ownership model. ScopedDBImp[T] provides generic
// CRUD and bulk operations for any T satisfying this interface without
// the permissionRef==NilObjectID sentinel pattern that ProtectedDBImp requires
// for personal-owned resources.
//
// Resources that embed model.PermissionBound and model.Owner satisfy
// ScopedStorable automatically once SetOwnerKind/SetOwnerRef methods are added.
type ScopedStorable interface {
OwnerBound
SetOwnerKind(OwnerKind)
SetOwnerRef(bson.ObjectID)
GetPermissionRef() bson.ObjectID
SetPermissionRef(bson.ObjectID)
}
+7
View File
@@ -0,0 +1,7 @@
package model
type SessionIdentifier struct {
ClientID string `bson:"clientId" json:"clientId"`
DeviceID string `bson:"deviceId" json:"deviceId"`
SessionVersion string `bson:"sessionVersion,omitempty" json:"sessionVersion,omitempty"`
}
+150
View File
@@ -0,0 +1,150 @@
package model
import (
"strings"
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/bson"
)
type SignupProvisioningState string
const (
SignupProvisioningStateStarted SignupProvisioningState = "started"
SignupProvisioningStateProvisioning SignupProvisioningState = "provisioning"
SignupProvisioningStateCompensating SignupProvisioningState = "compensating"
SignupProvisioningStateFailedPartialClean SignupProvisioningState = "failed_partial_cleanup"
SignupProvisioningStateFailedClean SignupProvisioningState = "failed_clean"
SignupProvisioningStateCompleted SignupProvisioningState = "completed"
)
type SignupProvisioningCleanupStatus string
const (
SignupProvisioningCleanupStatusNotNeeded SignupProvisioningCleanupStatus = "not_needed"
SignupProvisioningCleanupStatusPending SignupProvisioningCleanupStatus = "pending"
SignupProvisioningCleanupStatusRunning SignupProvisioningCleanupStatus = "running"
SignupProvisioningCleanupStatusComplete SignupProvisioningCleanupStatus = "complete"
SignupProvisioningCleanupStatusPartial SignupProvisioningCleanupStatus = "partial"
)
type SignupProvisioningTarget struct {
Chain ChainNetwork `bson:"chain" json:"chain"`
TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"`
ContractAddress string `bson:"contractAddress,omitempty" json:"contractAddress,omitempty"`
}
func (t *SignupProvisioningTarget) Normalize() {
if t == nil {
return
}
t.Chain = ChainNetwork(strings.TrimSpace(t.Chain.String()))
t.TokenSymbol = strings.ToUpper(strings.TrimSpace(t.TokenSymbol))
t.ContractAddress = strings.TrimSpace(t.ContractAddress)
}
type SignupProvisionedWallet struct {
Chain ChainNetwork `bson:"chain" json:"chain"`
WalletRef string `bson:"walletRef" json:"walletRef"`
}
func (w *SignupProvisionedWallet) Normalize() {
if w == nil {
return
}
w.Chain = ChainNetwork(strings.TrimSpace(w.Chain.String()))
w.WalletRef = strings.TrimSpace(w.WalletRef)
}
type SignupProvisionedRoute struct {
WalletRef string `bson:"walletRef" json:"walletRef"`
RouteRef string `bson:"routeRef" json:"routeRef"`
}
func (r *SignupProvisionedRoute) Normalize() {
if r == nil {
return
}
r.WalletRef = strings.TrimSpace(r.WalletRef)
r.RouteRef = strings.TrimSpace(r.RouteRef)
}
type SignupProvisioningRecord struct {
storable.Base `bson:",inline" json:",inline"`
NormalizedLogin string `bson:"normalizedLogin" json:"normalizedLogin"`
OrganizationRef string `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
AttemptID string `bson:"attemptId" json:"attemptId"`
State SignupProvisioningState `bson:"state" json:"state"`
CleanupStatus SignupProvisioningCleanupStatus `bson:"cleanupStatus" json:"cleanupStatus"`
LeaseOwner string `bson:"leaseOwner" json:"leaseOwner"`
LeaseExpiresAt time.Time `bson:"leaseExpiresAt" json:"leaseExpiresAt"`
HeartbeatAt time.Time `bson:"heartbeatAt" json:"heartbeatAt"`
SelectedTargets []SignupProvisioningTarget `bson:"selectedTargets,omitempty" json:"selectedTargets,omitempty"`
Wallets []SignupProvisionedWallet `bson:"wallets,omitempty" json:"wallets,omitempty"`
Routes []SignupProvisionedRoute `bson:"routes,omitempty" json:"routes,omitempty"`
StepStatus map[string]string `bson:"stepStatus,omitempty" json:"stepStatus,omitempty"`
LastError string `bson:"lastError,omitempty" json:"lastError,omitempty"`
LastCleanupError string `bson:"lastCleanupError,omitempty" json:"lastCleanupError,omitempty"`
ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"`
}
func (*SignupProvisioningRecord) Collection() string {
return mservice.SignupProvisioning
}
func (r *SignupProvisioningRecord) Normalize() {
if r == nil {
return
}
r.NormalizedLogin = strings.ToLower(strings.TrimSpace(r.NormalizedLogin))
r.OrganizationRef = strings.TrimSpace(r.OrganizationRef)
r.AttemptID = strings.TrimSpace(r.AttemptID)
r.LeaseOwner = strings.TrimSpace(r.LeaseOwner)
r.LastError = strings.TrimSpace(r.LastError)
r.LastCleanupError = strings.TrimSpace(r.LastCleanupError)
for i := range r.SelectedTargets {
r.SelectedTargets[i].Normalize()
}
for i := range r.Wallets {
r.Wallets[i].Normalize()
}
for i := range r.Routes {
r.Routes[i].Normalize()
}
if r.StepStatus == nil {
r.StepStatus = map[string]string{}
}
}
func (r *SignupProvisioningRecord) Active() bool {
if r == nil {
return false
}
switch r.State {
case SignupProvisioningStateStarted, SignupProvisioningStateProvisioning, SignupProvisioningStateCompensating:
return true
default:
return false
}
}
func (r *SignupProvisioningRecord) Terminal() bool {
if r == nil {
return false
}
switch r.State {
case SignupProvisioningStateFailedPartialClean, SignupProvisioningStateFailedClean, SignupProvisioningStateCompleted:
return true
default:
return false
}
}
func (r *SignupProvisioningRecord) OrganizationObjectID() bson.ObjectID {
ref, _ := bson.ObjectIDFromHex(strings.TrimSpace(r.OrganizationRef))
return ref
}
+30
View File
@@ -0,0 +1,30 @@
package model
type UserDataBase struct {
Login string `bson:"login" json:"login"`
Locale string `bson:"locale" json:"locale"`
}
type LoginData struct {
UserDataBase `bson:",inline" json:",inline"`
Password string ` bson:"-" json:"password"`
}
type AccountData struct {
LoginData `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
LastName string `bson:"lastName" json:"lastName"`
}
func (ad *AccountData) ToAccount() *Account {
return &Account{
AccountPublic: AccountPublic{
AccountBase: AccountBase{
Describable: ad.Describable,
LastName: ad.LastName,
},
UserDataBase: ad.UserDataBase,
},
Password: ad.Password,
}
}
+60
View File
@@ -0,0 +1,60 @@
package model
import (
"fmt"
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
type VerificationPurpose string
const (
PurposeAccountActivation VerificationPurpose = "account_activation"
PurposeEmailChange VerificationPurpose = "email_change"
PurposePasswordReset VerificationPurpose = "password_reset"
PurposeMagicLogin VerificationPurpose = "magic_login"
PurposeLogin VerificationPurpose = "login"
PurposePayout VerificationPurpose = "payout"
)
func VPToString(code VerificationPurpose) string {
return string(code)
}
func VPFromString(s string) (VerificationPurpose, error) {
switch s {
case string(PurposeAccountActivation):
return PurposeAccountActivation, nil
case string(PurposeEmailChange):
return PurposeEmailChange, nil
case string(PurposePasswordReset):
return PurposePasswordReset, nil
case string(PurposeMagicLogin):
return PurposeMagicLogin, nil
case string(PurposeLogin):
return PurposeLogin, nil
case string(PurposePayout):
return PurposePayout, nil
default:
return "", merrors.InvalidArgument(fmt.Sprintf("invalid verification purpose: %s", s))
}
}
type VerificationToken struct {
storable.Base `bson:",inline" json:",inline"`
Target string `bson:"target,omitempty" json:"target"`
ContextRef string `bson:"contextRef,omitempty" json:"contextRef,omitempty"`
AccountRef bson.ObjectID `bson:"accountRef" json:"accountRef"`
Purpose VerificationPurpose `bson:"purpose" json:"purpose"`
IdempotencyKey *string `bson:"idempotencyKey,omitempty" json:"-"`
VerifyTokenHash string `bson:"verifyTokenHash" json:"-"`
Salt *string `bson:"salt,omitempty" json:"-"`
UsedAt *time.Time `bson:"usedAt,omitempty" json:"-"`
ExpiresAt time.Time `bson:"expiresAt" json:"-"`
MaxRetries *int `bson:"maxRetries,omitempty" json:"-"`
Attempts int `bson:"attempts" json:"-"`
}
+8
View File
@@ -0,0 +1,8 @@
package model
// ViewCursor aggregates pagination and archival filters for list queries
type ViewCursor struct {
Limit *int64
Offset *int64
IsArchived *bool
}
+25
View File
@@ -0,0 +1,25 @@
package model
import (
"fmt"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
type WalletPaymentData struct {
WalletID string `bson:"walletId" json:"walletId"`
}
func (m *PaymentMethod) AsWallet() (*WalletPaymentData, error) {
if m.Type != PaymentTypeWallet {
return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not wallet", m.Type), "type")
}
var d WalletPaymentData
if err := bson.Unmarshal(m.Data, &d); err != nil {
return nil, err
}
return &d, nil
}
+105
View File
@@ -0,0 +1,105 @@
package model
import (
"strings"
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
)
type WalletProvisioningState string
const (
WalletProvisioningStateProvisioning WalletProvisioningState = "provisioning"
WalletProvisioningStateCompleted WalletProvisioningState = "completed"
WalletProvisioningStateFailed WalletProvisioningState = "failed"
)
type WalletProvisioningTargetState string
const (
WalletProvisioningTargetStatePending WalletProvisioningTargetState = "pending"
WalletProvisioningTargetStateWalletReady WalletProvisioningTargetState = "wallet_ready"
WalletProvisioningTargetStateSuspended WalletProvisioningTargetState = "suspended"
WalletProvisioningTargetStateRouted WalletProvisioningTargetState = "routed"
)
type WalletProvisioningTarget struct {
TargetID string `bson:"targetId" json:"targetId"`
Chain ChainNetwork `bson:"chain" json:"chain"`
Currency string `bson:"currency" json:"currency"`
ContractAddress string `bson:"contractAddress,omitempty" json:"contractAddress,omitempty"`
GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"`
GatewayURI string `bson:"gatewayUri,omitempty" json:"gatewayUri,omitempty"`
WalletIdempotencyKey string `bson:"walletIdempotencyKey,omitempty" json:"walletIdempotencyKey,omitempty"`
State WalletProvisioningTargetState `bson:"state" json:"state"`
WalletRef string `bson:"walletRef,omitempty" json:"walletRef,omitempty"`
Address string `bson:"address,omitempty" json:"address,omitempty"`
RouteRef string `bson:"routeRef,omitempty" json:"routeRef,omitempty"`
LastError string `bson:"lastError,omitempty" json:"lastError,omitempty"`
}
func (t *WalletProvisioningTarget) Normalize() {
if t == nil {
return
}
t.TargetID = strings.TrimSpace(t.TargetID)
t.Currency = strings.ToUpper(strings.TrimSpace(t.Currency))
t.ContractAddress = strings.TrimSpace(t.ContractAddress)
if strings.HasPrefix(strings.ToLower(t.ContractAddress), "0x") {
t.ContractAddress = strings.ToLower(t.ContractAddress)
}
t.GatewayID = strings.TrimSpace(t.GatewayID)
t.GatewayURI = strings.TrimSpace(t.GatewayURI)
t.WalletIdempotencyKey = strings.TrimSpace(t.WalletIdempotencyKey)
t.WalletRef = strings.TrimSpace(t.WalletRef)
t.Address = strings.TrimSpace(t.Address)
t.RouteRef = strings.TrimSpace(t.RouteRef)
t.LastError = strings.TrimSpace(t.LastError)
if t.State == "" {
t.State = WalletProvisioningTargetStatePending
}
}
type WalletProvisioningRecord struct {
storable.Base `bson:",inline" json:",inline"`
ProvisioningRef string `bson:"provisioningRef" json:"provisioningRef"`
OrganizationRef string `bson:"organizationRef" json:"organizationRef"`
OwnerRef string `bson:"ownerRef,omitempty" json:"ownerRef,omitempty"`
LedgerAccountRef string `bson:"ledgerAccountRef" json:"ledgerAccountRef"`
ActorRef string `bson:"actorRef,omitempty" json:"actorRef,omitempty"`
Reason string `bson:"reason" json:"reason"`
Currency string `bson:"currency" json:"currency"`
State WalletProvisioningState `bson:"state" json:"state"`
Targets []WalletProvisioningTarget `bson:"targets" json:"targets"`
LastError string `bson:"lastError,omitempty" json:"lastError,omitempty"`
// Version fences concurrent journal writers: Save replaces only the
// revision this attempt read, so a stale attempt whose lease was taken
// over cannot roll back the checkpoints of the attempt that owns it now.
Version int64 `bson:"version" json:"-"`
}
func (*WalletProvisioningRecord) Collection() string {
return mservice.WalletProvisioning
}
func (r *WalletProvisioningRecord) Normalize() {
if r == nil {
return
}
r.ProvisioningRef = strings.TrimSpace(r.ProvisioningRef)
r.OrganizationRef = strings.TrimSpace(r.OrganizationRef)
r.OwnerRef = strings.TrimSpace(r.OwnerRef)
r.LedgerAccountRef = strings.TrimSpace(r.LedgerAccountRef)
r.ActorRef = strings.TrimSpace(r.ActorRef)
r.Reason = strings.TrimSpace(r.Reason)
r.Currency = strings.ToUpper(strings.TrimSpace(r.Currency))
r.LastError = strings.TrimSpace(r.LastError)
for index := range r.Targets {
r.Targets[index].Normalize()
}
if r.State == "" {
r.State = WalletProvisioningStateProvisioning
}
}