Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
210070f
修复 Git 推送命令以确保正确推送标签到 deploy 分支
zeturn Jul 8, 2026
8245e1d
Add direct product purchase page
zeturn Jul 13, 2026
40ee5fe
Fix deploy secret shell escaping
zeturn Jul 13, 2026
57e9921
feat: allow resource-server token introspection
zeturn Jul 13, 2026
090190b
Set credit currency to microcredit precision
zeturn Jul 14, 2026
d523347
Add unified checkout cashier for wallet top-ups
zeturn Jul 14, 2026
0cf784c
Enable checkout wallet top-ups by default
zeturn Jul 14, 2026
529780a
Move top-up payment choice to cashier
zeturn Jul 14, 2026
9a579b6
Move gift card redeem off recharge page
zeturn Jul 14, 2026
345fa92
Support fiat checkout for wallet credit topups
zeturn Jul 14, 2026
7d8da91
Backfill wallet currency exchange rates
zeturn Jul 14, 2026
5057ec2
Add wallet currency pair rates
zeturn Jul 14, 2026
fa0426f
Display wallet history in transaction currency
zeturn Jul 14, 2026
817a48e
Show wallet total assets by account
zeturn Jul 14, 2026
abfd079
Improve wallet total contrast
zeturn Jul 14, 2026
59d9672
Add app wallet recharge targets
zeturn Jul 14, 2026
ff408a8
Use integer credit point currency
zeturn Jul 14, 2026
a7a1bac
Fix app recharge route and credit exchange rates
zeturn Jul 14, 2026
2c0de87
Fix app recharge client tenant lookup
zeturn Jul 14, 2026
9deff9c
Honor tenant query for app recharge
zeturn Jul 14, 2026
22ca50f
Show APICred wallet transactions as credits
zeturn Jul 14, 2026
a9650de
Fix integer wallet recharge amount validation
zeturn Jul 14, 2026
3140058
Reconcile wallet top-ups after Stripe return
zeturn Jul 14, 2026
10490e1
Record Stripe session on wallet top-up transactions
zeturn Jul 14, 2026
d51f14a
Return to app after top-up success
zeturn Jul 14, 2026
eb6e0c2
feat: add approval-based RBAC manifest import
zeturn Jul 14, 2026
648a7c7
Support tenant-scoped wallet owners
zeturn Jul 15, 2026
4994a91
Fix wallet precision test
zeturn Jul 15, 2026
c2dc845
Backfill tenant for legacy teams
zeturn Jul 15, 2026
b15289a
feat: add tenant-to-app RBAC grant mappings
zeturn Jul 17, 2026
b3664f8
Merge remote-tracking branch 'origin/deploy' into deploy
zeturn Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.git
.gitignore
**/node_modules
**/dist
**/.vite
**/coverage
**/.DS_Store
49 changes: 33 additions & 16 deletions .github/workflows/ghcr-k3s-cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -196,56 +196,73 @@ jobs:

- name: Configure kubeconfig
shell: bash
env:
KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG }}
run: |
if [ -z "${{ secrets.KUBE_CONFIG }}" ]; then
if [ -z "${KUBE_CONFIG_DATA}" ]; then
echo "KUBE_CONFIG secret is required"
exit 1
fi
mkdir -p ~/.kube
printf '%s' "${{ secrets.KUBE_CONFIG }}" > ~/.kube/config
printf '%s' "${KUBE_CONFIG_DATA}" > ~/.kube/config
chmod 600 ~/.kube/config

- name: Ensure namespace and pull secret
shell: bash
env:
GHCR_PULL_TOKEN: ${{ secrets.GHCR_PAT || secrets.GITHUB_TOKEN }}
HARBOR_HOST_RAW: ${{ secrets.BEANCS_REGISTRY_HOST }}
HARBOR_USERNAME: ${{ secrets.BEANCS_REGISTRY_USERNAME }}
HARBOR_TOKEN: ${{ secrets.BEANCS_REGISTRY_TOKEN }}
run: |
OWNER_LC="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')"
HARBOR_HOST="${{ secrets.BEANCS_REGISTRY_HOST }}"
HARBOR_HOST="${HARBOR_HOST_RAW}"
HARBOR_HOST="${HARBOR_HOST#http://}"
HARBOR_HOST="${HARBOR_HOST#https://}"
HARBOR_HOST="${HARBOR_HOST%/}"
kubectl get namespace "${NAMESPACE}" >/dev/null 2>&1 || kubectl create namespace "${NAMESPACE}"
kubectl -n "${NAMESPACE}" create secret docker-registry ghcr-creds \
--docker-server=ghcr.io \
--docker-username="${OWNER_LC}" \
--docker-password="${{ secrets.GHCR_PAT || secrets.GITHUB_TOKEN }}" \
--docker-password="${GHCR_PULL_TOKEN}" \
--dry-run=client -o yaml | kubectl apply -f -
if [[ -n "${HARBOR_HOST}" && -n "${{ secrets.BEANCS_REGISTRY_USERNAME }}" && -n "${{ secrets.BEANCS_REGISTRY_TOKEN }}" ]]; then
if [[ -n "${HARBOR_HOST}" && -n "${HARBOR_USERNAME}" && -n "${HARBOR_TOKEN}" ]]; then
kubectl -n "${NAMESPACE}" create secret docker-registry beancs-registry-pull \
--docker-server="${HARBOR_HOST}" \
--docker-username="${{ secrets.BEANCS_REGISTRY_USERNAME }}" \
--docker-password="${{ secrets.BEANCS_REGISTRY_TOKEN }}" \
--docker-username="${HARBOR_USERNAME}" \
--docker-password="${HARBOR_TOKEN}" \
--dry-run=client -o yaml | kubectl apply -f -
fi

- name: Configure BasaltPass backend secret
shell: bash
env:
PROD_JWT_SECRET: ${{ secrets.PROD_JWT_SECRET }}
BASALTPASS_DATABASE_DRIVER: ${{ vars.BASALTPASS_DATABASE_DRIVER || 'postgres' }}
BASALTPASS_DATABASE_DSN: ${{ secrets.BASALTPASS_DATABASE_DSN }}
BASALTPASS_CORS_ALLOW_ORIGINS: ${{ vars.BASALTPASS_CORS_ALLOW_ORIGINS }}
BASALTPASS_EMAIL_PROVIDER: ${{ vars.BASALTPASS_EMAIL_PROVIDER }}
BASALTPASS_EMAIL_FROM: ${{ vars.BASALTPASS_EMAIL_FROM }}
BASALTPASS_EMAIL_AWS_SES_REGION: ${{ secrets.BASALTPASS_EMAIL_AWS_SES_REGION }}
BASALTPASS_EMAIL_AWS_SES_ACCESS_KEY_ID: ${{ secrets.BASALTPASS_EMAIL_AWS_SES_ACCESS_KEY_ID }}
BASALTPASS_EMAIL_AWS_SES_SECRET_ACCESS_KEY: ${{ secrets.BASALTPASS_EMAIL_AWS_SES_SECRET_ACCESS_KEY }}
run: |
PUBLIC_URL="${{ vars.BASALTPASS_PUBLIC_URL }}"
PUBLIC_URL="${PUBLIC_URL%/}"
kubectl -n "${NAMESPACE}" create secret generic basaltpass-env \
--from-literal=JWT_SECRET="${{ secrets.PROD_JWT_SECRET }}" \
--from-literal=JWT_SECRET="${PROD_JWT_SECRET}" \
--from-literal=BASALTPASS_ENV=production \
--from-literal=BASALTPASS_SERVER_ADDRESS=:8101 \
--from-literal=BASALTPASS_UI_BASE_URL="${PUBLIC_URL}" \
--from-literal=BASALTPASS_OIDC_ISSUER="${PUBLIC_URL}/api/v1" \
--from-literal=BASALTPASS_DATABASE_DRIVER="${{ vars.BASALTPASS_DATABASE_DRIVER || 'postgres' }}" \
--from-literal=BASALTPASS_DATABASE_DSN="${{ secrets.BASALTPASS_DATABASE_DSN }}" \
--from-literal=BASALTPASS_CORS_ALLOW_ORIGINS="${{ vars.BASALTPASS_CORS_ALLOW_ORIGINS }}" \
--from-literal=BASALTPASS_EMAIL_PROVIDER="${{ vars.BASALTPASS_EMAIL_PROVIDER }}" \
--from-literal=BASALTPASS_EMAIL_FROM="${{ vars.BASALTPASS_EMAIL_FROM }}" \
--from-literal=BASALTPASS_EMAIL_AWS_SES_REGION="${{ secrets.BASALTPASS_EMAIL_AWS_SES_REGION }}" \
--from-literal=BASALTPASS_EMAIL_AWS_SES_ACCESS_KEY_ID="${{ secrets.BASALTPASS_EMAIL_AWS_SES_ACCESS_KEY_ID }}" \
--from-literal=BASALTPASS_EMAIL_AWS_SES_SECRET_ACCESS_KEY="${{ secrets.BASALTPASS_EMAIL_AWS_SES_SECRET_ACCESS_KEY }}" \
--from-literal=BASALTPASS_DATABASE_DRIVER="${BASALTPASS_DATABASE_DRIVER}" \
--from-literal=BASALTPASS_DATABASE_DSN="${BASALTPASS_DATABASE_DSN}" \
--from-literal=BASALTPASS_CORS_ALLOW_ORIGINS="${BASALTPASS_CORS_ALLOW_ORIGINS}" \
--from-literal=BASALTPASS_EMAIL_PROVIDER="${BASALTPASS_EMAIL_PROVIDER}" \
--from-literal=BASALTPASS_EMAIL_FROM="${BASALTPASS_EMAIL_FROM}" \
--from-literal=BASALTPASS_EMAIL_AWS_SES_REGION="${BASALTPASS_EMAIL_AWS_SES_REGION}" \
--from-literal=BASALTPASS_EMAIL_AWS_SES_ACCESS_KEY_ID="${BASALTPASS_EMAIL_AWS_SES_ACCESS_KEY_ID}" \
--from-literal=BASALTPASS_EMAIL_AWS_SES_SECRET_ACCESS_KEY="${BASALTPASS_EMAIL_AWS_SES_SECRET_ACCESS_KEY}" \
--dry-run=client -o yaml | kubectl apply -f -

- name: Deploy BasaltPass workloads
Expand Down
1 change: 1 addition & 0 deletions basaltpass-backend/internal/api/v1/routes/public.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func RegisterPublicRoutes(v1 fiber.Router) {
// 货币系统路由(公开API,不需要认证)
currencyGroup := v1.Group("/currencies")
currencyGroup.Get("/", currency.GetCurrenciesHandler)
currencyGroup.Get("/rates", currency.GetCurrencyRatesHandler)
currencyGroup.Get("/:code", currency.GetCurrencyHandler)

// 公共安全路由(无需登录,带速率限制)
Expand Down
7 changes: 7 additions & 0 deletions basaltpass-backend/internal/api/v1/routes/s2s.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ func RegisterS2SRoutes(v1 fiber.Router) {
group.Get("/users/:id/role-codes", middleware.ClientScopeMiddleware(sc.S2SRBACRead), s2sHandler.GetUserRoleCodesHandler)
group.Get("/users/:id/permissions", middleware.ClientScopeMiddleware(sc.S2SRBACRead), s2sHandler.GetUserPermissionsHandler)

// RBAC manifest control plane: the authenticated app may submit and poll
// only its own drafts. Publishing remains tenant-admin only.
group.Post("/rbac/manifests", middleware.ClientScopeMiddleware(sc.S2SRBACManifestSubmit), s2sHandler.SubmitRBACManifestHandler)
group.Get("/rbac/manifests/:id", middleware.ClientScopeMiddleware(sc.S2SRBACManifestSubmit), s2sHandler.GetOwnRBACManifestHandler)

// 团队
group.Get("/teams", middleware.ClientScopeMiddleware(sc.S2STeamRead), s2sHandler.ListTeamsHandler)
group.Post("/teams", middleware.ClientScopeMiddleware(sc.S2STeamWrite), s2sHandler.CreateTeamHandler)
Expand All @@ -37,6 +42,8 @@ func RegisterS2SRoutes(v1 fiber.Router) {
// 钱包数据(需要 currency 参数)
group.Get("/users/:id/wallets", middleware.ClientScopeMiddleware(sc.S2SWalletRead), s2sHandler.GetUserWalletHandler)
group.Post("/users/:id/wallets/adjust", middleware.ClientScopeMiddleware(sc.S2SWalletWrite), s2sHandler.AdjustUserWalletHandler)
group.Get("/wallets/:owner_type/:owner_id", middleware.ClientScopeMiddleware(sc.S2SWalletRead), s2sHandler.GetOwnerWalletHandler)
group.Post("/wallets/:owner_type/:owner_id/adjust", middleware.ClientScopeMiddleware(sc.S2SWalletWrite), s2sHandler.AdjustOwnerWalletHandler)

// 用户消息(通知)与商品拥有
group.Get("/users/:id/messages", middleware.ClientScopeMiddleware(sc.S2SMessagesRead), s2sHandler.GetUserMessagesHandler)
Expand Down
21 changes: 21 additions & 0 deletions basaltpass-backend/internal/api/v1/routes/tenant.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ func RegisterTenantRoutes(v1 fiber.Router) {
tenantAppGroup.Delete("/:id", app.TenantDeleteAppHandler)
tenantAppGroup.Get("/:id/stats", app.TenantGetAppStatsHandler)
tenantAppGroup.Patch("/:id/status", app.TenantToggleAppStatusHandler)
tenantAppGroup.Get("/:id/wallet-currencies", app.TenantListAppWalletCurrenciesHandler)
tenantAppGroup.Put("/:id/wallet-currencies", app.TenantReplaceAppWalletCurrenciesHandler)

// 应用权限管理路由
tenantAppGroup.Get("/:app_id/permissions", app_rbac2.GetAppPermissions)
Expand All @@ -256,6 +258,24 @@ func RegisterTenantRoutes(v1 fiber.Router) {
tenantAppGroup.Put("/:app_id/roles/:role_id", app_rbac2.UpdateAppRole)
tenantAppGroup.Delete("/:app_id/roles/:role_id", app_rbac2.DeleteAppRole)

// App-submitted RBAC manifests. These routes are tenant-admin protected;
// apps can submit drafts only through the S2S endpoint above.
tenantAppGroup.Get("/:app_id/rbac/manifests", tenant2.ListAppRBACManifestsHandler)
tenantAppGroup.Get("/:app_id/rbac/manifests/:manifest_id", tenant2.GetAppRBACManifestHandler)
tenantAppGroup.Post("/:app_id/rbac/manifests/:manifest_id/approve", tenant2.ApproveAppRBACManifestHandler)
tenantAppGroup.Post("/:app_id/rbac/manifests/:manifest_id/reject", tenant2.RejectAppRBACManifestHandler)
tenantAppGroup.Get("/:app_id/rbac/revisions", tenant2.ListAppRBACRevisionsHandler)
tenantAppGroup.Post("/:app_id/rbac/revisions/:revision_id/rollback", tenant2.RollbackAppRBACRevisionHandler)

// Tenant-owned dynamic mappings. These policies are evaluated at read time
// and never create inherited app_user_roles/app_user_permissions rows.
tenantAppGroup.Get("/:app_id/rbac/mappings", tenant2.ListAppGrantMappingsHandler)
tenantAppGroup.Get("/:app_id/rbac/mappings/options", tenant2.GetAppGrantMappingOptionsHandler)
tenantAppGroup.Post("/:app_id/rbac/mappings/preview", tenant2.PreviewAppGrantMappingHandler)
tenantAppGroup.Post("/:app_id/rbac/mappings", tenant2.CreateAppGrantMappingHandler)
tenantAppGroup.Put("/:app_id/rbac/mappings/:mapping_id", tenant2.UpdateAppGrantMappingHandler)
tenantAppGroup.Delete("/:app_id/rbac/mappings/:mapping_id", tenant2.DeleteAppGrantMappingHandler)

// 应用用户管理路由(包含权限)
tenantAppGroup.Get("/:app_id/users", app_rbac2.GetAppUsers)
tenantAppGroup.Get("/:app_id/users/by-status", app_user.GetAppUsersByStatusHandler)
Expand All @@ -265,6 +285,7 @@ func RegisterTenantRoutes(v1 fiber.Router) {
// 应用用户权限管理路由
tenantAppGroup.Get("/:app_id/users/:user_id/permissions", app_rbac2.GetUserPermissions)
tenantAppGroup.Get("/:app_id/users/:user_id/roles", app_rbac2.GetUserRoles)
tenantAppGroup.Get("/:app_id/users/:user_id/effective-grants", tenant2.GetEffectiveAppUserGrantsHandler)
tenantAppGroup.Post("/:app_id/users/:user_id/check-access", app_rbac2.CheckUserAccess)
tenantAppGroup.Post("/:app_id/users/:user_id/permissions", app_rbac2.GrantUserPermissions)
tenantAppGroup.Delete("/:app_id/users/:user_id/permissions/:permission_id", app_rbac2.RevokeUserPermission)
Expand Down
5 changes: 5 additions & 0 deletions basaltpass-backend/internal/api/v1/routes/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func RegisterUserRoutes(v1 fiber.Router) {

// 用户应用授权管理
userGroup.Get("/apps", app_user.GetUserAppsHandler)
userGroup.Get("/apps/recharge-config", user.GetAppRechargeConfigHandler)
userGroup.Delete("/apps/:app_id", app_user.RevokeUserAppHandler)

// 用户搜索路由 (需要JWT认证)
Expand Down Expand Up @@ -87,6 +88,8 @@ func RegisterUserRoutes(v1 fiber.Router) {
teamGroup.Put("/:id", userTeam.UpdateTeamHandler)
teamGroup.Delete("/:id", userTeam.DeleteTeamHandler)
teamGroup.Get("/:id/members", userTeam.GetTeamMembersHandler)
teamGroup.Get("/:id/wallets", userTeam.GetTeamWalletsHandler)
teamGroup.Get("/:id/wallets/history", userTeam.GetTeamWalletHistoryHandler)
teamGroup.Post("/:id/members", userTeam.AddMemberHandler)
teamGroup.Put("/:id/members/:member_id", userTeam.UpdateMemberRoleHandler)
teamGroup.Delete("/:id/members/:member_id", userTeam.RemoveMemberHandler)
Expand All @@ -106,6 +109,7 @@ func RegisterUserRoutes(v1 fiber.Router) {
// 钱包用户系统路由(需要认证)
walletGroup := v1.Group("/wallet", middleware.JWTMiddleware())
walletGroup.Get("/balance", user.GetWalletBalanceHandler)
walletGroup.Get("/accounts", user.GetWalletAccountsHandler)
walletGroup.Post("/recharge", user.RechargeWalletHandler)
walletGroup.Post("/withdraw", user.WithdrawWalletHandler)
walletGroup.Get("/history", user.WalletHistoryHandler)
Expand All @@ -117,6 +121,7 @@ func RegisterUserRoutes(v1 fiber.Router) {
paymentGroup.Get("/intents", payment.ListPaymentIntentsHandler)
paymentGroup.Get("/intents/:id", payment.GetPaymentIntentHandler)
paymentGroup.Post("/sessions", payment.CreatePaymentSessionHandler)
paymentGroup.Post("/sessions/:session_id/reconcile-wallet-top-up", payment.ReconcileWalletTopUpSessionHandler)
paymentGroup.Get("/sessions/:session_id", payment.GetPaymentSessionHandler)

// 订单系统路由
Expand Down
60 changes: 39 additions & 21 deletions basaltpass-backend/internal/handler/admin/wallet/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,20 @@ import (

// WalletResponse represents a wallet with computed status field for API response
type WalletResponse struct {
ID uint `json:"id"`
TenantID uint `json:"tenant_id"`
UserID *uint `json:"user_id"`
TeamID *uint `json:"team_id"`
CurrencyID *uint `json:"currency_id"`
Balance int64 `json:"balance"`
Status string `json:"status"` // computed from Freeze field
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Currency interface{} `json:"currency,omitempty"`
User interface{} `json:"user,omitempty"`
Team interface{} `json:"team,omitempty"`
ID uint `json:"id"`
TenantID uint `json:"tenant_id"`
OwnerType model.WalletOwnerType `json:"owner_type"`
OwnerID uint `json:"owner_id"`
UserID *uint `json:"user_id"`
TeamID *uint `json:"team_id"`
CurrencyID *uint `json:"currency_id"`
Balance int64 `json:"balance"`
Status string `json:"status"` // computed from Freeze field
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Currency interface{} `json:"currency,omitempty"`
User interface{} `json:"user,omitempty"`
Team interface{} `json:"team,omitempty"`
}

// AdminWalletHandler handles tenant wallet operations
Expand All @@ -46,6 +48,8 @@ func walletToResponse(wallet model.Wallet) WalletResponse {
return WalletResponse{
ID: wallet.ID,
TenantID: wallet.TenantID,
OwnerType: wallet.OwnerType,
OwnerID: wallet.OwnerID,
UserID: wallet.UserID,
TeamID: wallet.TeamID,
CurrencyID: wallet.CurrencyID,
Expand Down Expand Up @@ -79,10 +83,12 @@ type ListWalletsRequest struct {

// CreateWalletRequest represents the request for creating a wallet
type CreateWalletRequest struct {
UserID *uint `json:"user_id"`
TeamID *uint `json:"team_id"`
CurrencyCode string `json:"currency_code" validate:"required"`
InitialBalance float64 `json:"initial_balance"`
OwnerType model.WalletOwnerType `json:"owner_type"`
OwnerID *uint `json:"owner_id"`
UserID *uint `json:"user_id"`
TeamID *uint `json:"team_id"`
CurrencyCode string `json:"currency_code" validate:"required"`
InitialBalance float64 `json:"initial_balance"`
}

// AdjustBalanceRequest represents the request for adjusting wallet balance
Expand Down Expand Up @@ -205,13 +211,23 @@ func (h *AdminWalletHandler) CreateWallet(c *fiber.Ctx) error {
})
}

// Validate that either UserID or TeamID is provided, but not both
if req.UserID == nil && req.TeamID == nil {
usesUnifiedOwner := req.OwnerType != "" || req.OwnerID != nil
if usesUnifiedOwner && (req.OwnerType == "" || req.OwnerID == nil || !req.OwnerType.IsValid()) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Either user_id or team_id must be provided",
"error": "owner_type and owner_id must identify a user, app, tenant, or team",
})
}
if req.UserID != nil && req.TeamID != nil {
if usesUnifiedOwner && (req.UserID != nil || req.TeamID != nil) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "cannot mix owner_type/owner_id with legacy user_id/team_id",
})
}
if !usesUnifiedOwner && req.UserID == nil && req.TeamID == nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "owner_type/owner_id, user_id, or team_id must be provided",
})
}
if !usesUnifiedOwner && req.UserID != nil && req.TeamID != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Cannot specify both user_id and team_id",
})
Expand All @@ -220,7 +236,9 @@ func (h *AdminWalletHandler) CreateWallet(c *fiber.Ctx) error {
var wallet *model.Wallet
var err error

if req.UserID != nil {
if usesUnifiedOwner {
wallet, err = h.service.CreateWalletForOwner(req.OwnerType, *req.OwnerID, req.CurrencyCode, req.InitialBalance)
} else if req.UserID != nil {
wallet, err = h.service.CreateWalletForUser(*req.UserID, req.CurrencyCode, req.InitialBalance)
} else {
wallet, err = h.service.CreateWalletForTeam(*req.TeamID, req.CurrencyCode, req.InitialBalance)
Expand Down
54 changes: 53 additions & 1 deletion basaltpass-backend/internal/handler/admin/wallet/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"basaltpass-backend/internal/common"
"basaltpass-backend/internal/model"
"basaltpass-backend/internal/service/currency"
walletservice "basaltpass-backend/internal/service/wallet"
"errors"
"fmt"
"strings"
Expand Down Expand Up @@ -279,6 +280,57 @@ func (s *AdminWalletService) CreateWalletForTeam(teamID uint, currencyCode strin
return &newWallet, nil
}

// CreateWalletForOwner creates a wallet for any supported tenant-scoped owner.
func (s *AdminWalletService) CreateWalletForOwner(ownerType model.WalletOwnerType, ownerID uint, currencyCode string, initialBalance float64) (*model.Wallet, error) {
if initialBalance < 0 {
return nil, errors.New("initial balance must not be negative")
}
curr, err := currency.GetCurrencyByCode(currencyCode)
if err != nil {
return nil, fmt.Errorf("invalid currency code: %s", currencyCode)
}
db := common.DB()
owner, err := walletservice.ResolveOwnerRef(db, ownerType, ownerID, 0)
if err != nil {
return nil, err
}

var existing model.Wallet
err = db.Where("tenant_id = ? AND owner_type = ? AND owner_id = ? AND currency_id = ?", owner.TenantID, owner.Type, owner.ID, curr.ID).
First(&existing).Error
if err == nil {
return nil, errors.New("wallet already exists for this owner and currency")
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}

balance := convertToSmallestUnit(initialBalance, curr.DecimalPlaces)
walletModel := model.Wallet{
TenantID: owner.TenantID, OwnerType: owner.Type, OwnerID: owner.ID,
CurrencyID: &curr.ID, Balance: balance,
}
err = db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&walletModel).Error; err != nil {
return err
}
if balance == 0 {
return nil
}
return tx.Create(&model.WalletTx{
WalletID: walletModel.ID, Type: "admin_deposit", Amount: balance,
Status: "success", Reference: "Admin created owner wallet with initial balance",
}).Error
})
if err != nil {
return nil, err
}
if err := db.Preload("Currency").First(&walletModel, walletModel.ID).Error; err != nil {
return nil, err
}
return &walletModel, nil
}

// AdjustBalance adjusts wallet balance (tenant operation)
func (s *AdminWalletService) AdjustBalance(walletID uint, amount float64, reason string, operatorID uint) error {
db := common.DB()
Expand Down Expand Up @@ -573,7 +625,7 @@ func (s *AdminWalletService) DeleteWallet(walletID uint, operatorID uint) error
}

// Delete wallet
if err := tx.Delete(&walletModel).Error; err != nil {
if err := tx.Unscoped().Delete(&walletModel).Error; err != nil {
return err
}

Expand Down
Loading
Loading