mirror of
https://github.com/we-promise/sure.git
synced 2026-06-01 08:49:01 +00:00
Merge remote-tracking branch 'origin/main' into feat/goals-v2-architecture
# Conflicts: # .github/workflows/preview-deploy.yml # app/models/account/provider_import_adapter.rb
This commit is contained in:
@@ -55,6 +55,47 @@ class BinanceItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_equal "Crypto", binance_account.current_account.accountable_type
|
||||
end
|
||||
|
||||
test "complete_account_setup updates sync_start_date when provided with a valid past date" do
|
||||
binance_account = @binance_item.binance_accounts.create!(
|
||||
name: "Spot Portfolio",
|
||||
account_type: "spot",
|
||||
currency: "USD",
|
||||
current_balance: 1000.0
|
||||
)
|
||||
|
||||
past_date = (Date.current - 7.days).to_s
|
||||
|
||||
post complete_account_setup_binance_item_url(@binance_item), params: {
|
||||
selected_accounts: [ binance_account.id ],
|
||||
sync_start_date: past_date
|
||||
}
|
||||
|
||||
assert_response :redirect
|
||||
@binance_item.reload
|
||||
assert_equal Date.parse(past_date), @binance_item.sync_start_date
|
||||
end
|
||||
|
||||
test "complete_account_setup rejects a future sync_start_date and sets flash alert" do
|
||||
binance_account = @binance_item.binance_accounts.create!(
|
||||
name: "Spot Portfolio",
|
||||
account_type: "spot",
|
||||
currency: "USD",
|
||||
current_balance: 1000.0
|
||||
)
|
||||
|
||||
future_date = (Date.current + 2.days).to_s
|
||||
original_sync_date = @binance_item.sync_start_date
|
||||
|
||||
post complete_account_setup_binance_item_url(@binance_item), params: {
|
||||
selected_accounts: [ binance_account.id ],
|
||||
sync_start_date: future_date
|
||||
}
|
||||
|
||||
@binance_item.reload
|
||||
assert_nil @binance_item.sync_start_date
|
||||
assert_equal "Sync start date must be a valid date in the past.", flash[:alert]
|
||||
end
|
||||
|
||||
test "complete_account_setup with no selection shows message" do
|
||||
@binance_item.binance_accounts.create!(
|
||||
name: "Spot Portfolio",
|
||||
|
||||
@@ -2,7 +2,8 @@ require "test_helper"
|
||||
|
||||
class CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in users(:family_admin)
|
||||
sign_in @user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@transaction = transactions :one
|
||||
ensure_tailwind_build
|
||||
end
|
||||
@@ -95,4 +96,208 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
assert_redirected_to categories_url
|
||||
end
|
||||
|
||||
test "merge renders in the settings layout" do
|
||||
get merge_categories_path
|
||||
|
||||
assert_response :success
|
||||
assert_select "#mobile-settings-nav"
|
||||
end
|
||||
|
||||
test "merge renders without the settings layout for modal frame requests" do
|
||||
get merge_categories_path, headers: { "Turbo-Frame" => "modal" }
|
||||
|
||||
assert_response :success
|
||||
assert_no_match(/<html/i, response.body)
|
||||
assert_no_match(/<turbo-frame id="modal"><\/turbo-frame>/, response.body)
|
||||
assert_select "dialog"
|
||||
end
|
||||
|
||||
test "merge selected categories into an existing category" do
|
||||
target = @family.categories.create!(
|
||||
name: "Dining",
|
||||
color: "#111111",
|
||||
lucide_icon: "utensils"
|
||||
)
|
||||
source = @family.categories.create!(
|
||||
name: "Coffee Shops",
|
||||
color: "#000000",
|
||||
lucide_icon: "coffee"
|
||||
)
|
||||
transaction = Transaction.create!(category: source)
|
||||
Entry.create!(
|
||||
account: accounts(:depository),
|
||||
entryable: transaction,
|
||||
name: "Coffee transaction",
|
||||
date: Date.current,
|
||||
amount: 10,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
assert_difference "Category.count", -1 do
|
||||
post perform_merge_categories_path, params: {
|
||||
target_id: target.id,
|
||||
source_ids: [ source.id ]
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to categories_path
|
||||
assert_equal target, transaction.reload.category
|
||||
assert_not Category.exists?(source.id)
|
||||
end
|
||||
|
||||
test "merge redirects when a source category cannot be destroyed" do
|
||||
target = @family.categories.create!(
|
||||
name: "Destroy Failure Target",
|
||||
color: "#000000",
|
||||
lucide_icon: "shapes"
|
||||
)
|
||||
source = @family.categories.create!(
|
||||
name: "Destroy Failure Source",
|
||||
color: "#111111",
|
||||
lucide_icon: "shapes"
|
||||
)
|
||||
|
||||
Category::Merger.any_instance
|
||||
.stubs(:merge!)
|
||||
.raises(ActiveRecord::RecordNotDestroyed.new("cannot destroy category", source))
|
||||
|
||||
post perform_merge_categories_path, params: {
|
||||
target_id: target.id,
|
||||
source_ids: [ source.id ]
|
||||
}
|
||||
|
||||
assert_redirected_to merge_categories_path
|
||||
assert Category.exists?(source.id)
|
||||
end
|
||||
|
||||
test "merge rejects selecting the target as a source" do
|
||||
target = @family.categories.create!(
|
||||
name: "Self Target",
|
||||
color: "#000000",
|
||||
lucide_icon: "shapes"
|
||||
)
|
||||
source = @family.categories.create!(
|
||||
name: "Self Source",
|
||||
color: "#111111",
|
||||
lucide_icon: "shapes"
|
||||
)
|
||||
|
||||
post perform_merge_categories_path, params: {
|
||||
target_id: target.id,
|
||||
source_ids: [ target.id, source.id ]
|
||||
}
|
||||
|
||||
assert_redirected_to merge_categories_path
|
||||
assert Category.exists?(target.id)
|
||||
assert Category.exists?(source.id)
|
||||
end
|
||||
|
||||
test "merge rejects parent category into any descendant" do
|
||||
parent = @family.categories.create!(
|
||||
name: "Parent Category",
|
||||
color: "#000000",
|
||||
lucide_icon: "folder"
|
||||
)
|
||||
child = @family.categories.create!(
|
||||
name: "Child Category",
|
||||
color: "#111111",
|
||||
lucide_icon: "folder",
|
||||
parent: parent
|
||||
)
|
||||
grandchild = @family.categories.create!(
|
||||
name: "Grandchild Category",
|
||||
color: "#222222",
|
||||
lucide_icon: "folder"
|
||||
)
|
||||
# Category validation normally prevents this depth; the merger still guards
|
||||
# against stale or imported data with deeper hierarchies.
|
||||
grandchild.update_column(:parent_id, child.id)
|
||||
|
||||
post perform_merge_categories_path, params: {
|
||||
target_id: grandchild.id,
|
||||
source_ids: [ parent.id ]
|
||||
}
|
||||
|
||||
assert_redirected_to merge_categories_path
|
||||
assert Category.exists?(parent.id)
|
||||
end
|
||||
|
||||
test "merge reparents source children to target category" do
|
||||
target = @family.categories.create!(
|
||||
name: "Target Category",
|
||||
color: "#000000",
|
||||
lucide_icon: "folder"
|
||||
)
|
||||
source = @family.categories.create!(
|
||||
name: "Source Category",
|
||||
color: "#111111",
|
||||
lucide_icon: "folder"
|
||||
)
|
||||
child = @family.categories.create!(
|
||||
name: "Source Child Category",
|
||||
color: "#222222",
|
||||
lucide_icon: "folder",
|
||||
parent: source
|
||||
)
|
||||
|
||||
post perform_merge_categories_path, params: {
|
||||
target_id: target.id,
|
||||
source_ids: [ source.id ]
|
||||
}
|
||||
|
||||
assert_redirected_to categories_path
|
||||
assert_equal target.id, child.reload.parent_id
|
||||
assert_not Category.exists?(source.id)
|
||||
end
|
||||
|
||||
test "merge rejects moving source children under a subcategory target" do
|
||||
parent = @family.categories.create!(
|
||||
name: "Target Parent Category",
|
||||
color: "#000000",
|
||||
lucide_icon: "folder"
|
||||
)
|
||||
target = @family.categories.create!(
|
||||
name: "Target Subcategory",
|
||||
color: "#111111",
|
||||
lucide_icon: "folder",
|
||||
parent: parent
|
||||
)
|
||||
source = @family.categories.create!(
|
||||
name: "Source With Child",
|
||||
color: "#222222",
|
||||
lucide_icon: "folder"
|
||||
)
|
||||
child = @family.categories.create!(
|
||||
name: "Source Child",
|
||||
color: "#333333",
|
||||
lucide_icon: "folder",
|
||||
parent: source
|
||||
)
|
||||
|
||||
post perform_merge_categories_path, params: {
|
||||
target_id: target.id,
|
||||
source_ids: [ source.id ]
|
||||
}
|
||||
|
||||
assert_redirected_to merge_categories_path
|
||||
assert Category.exists?(source.id)
|
||||
assert_equal source.id, child.reload.parent_id
|
||||
end
|
||||
|
||||
test "merge ignores categories outside current family" do
|
||||
other = families(:empty).categories.create!(
|
||||
name: "Other Family Category",
|
||||
color: "#000000",
|
||||
lucide_icon: "shapes"
|
||||
)
|
||||
|
||||
post perform_merge_categories_path, params: {
|
||||
target_id: categories(:income).id,
|
||||
source_ids: [ other.id ]
|
||||
}
|
||||
|
||||
assert_redirected_to merge_categories_path
|
||||
assert Category.exists?(other.id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
require "test_helper"
|
||||
|
||||
class ImportsControllerTest < ActionDispatch::IntegrationTest
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
setup do
|
||||
sign_in @user = users(:family_admin)
|
||||
ensure_tailwind_build
|
||||
@@ -85,18 +87,242 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
|
||||
@user.family.expects(:upload_document).never
|
||||
|
||||
assert_difference "Import.count", 1 do
|
||||
assert_difference "AccountStatement.count", 1 do
|
||||
post imports_url, params: {
|
||||
import: {
|
||||
type: "DocumentImport",
|
||||
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
created_import = Import.order(:created_at).last
|
||||
assert_equal "PdfImport", created_import.type
|
||||
assert_equal AccountStatement.order(:created_at).last, created_import.account_statement
|
||||
assert_not created_import.pdf_file.attached?
|
||||
assert_redirected_to import_url(created_import)
|
||||
assert_equal I18n.t("imports.create.pdf_processing"), flash[:notice]
|
||||
end
|
||||
|
||||
test "uploads pdf import through account statement" do
|
||||
assert_difference "AccountStatement.count", 1 do
|
||||
assert_difference "Import.where(type: 'PdfImport').count", 1 do
|
||||
post imports_url, params: {
|
||||
import: {
|
||||
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
statement = AccountStatement.order(:created_at).last
|
||||
created_import = PdfImport.order(:created_at).last
|
||||
assert_equal statement, created_import.account_statement
|
||||
assert_not created_import.pdf_file.attached?
|
||||
assert_redirected_to import_url(created_import)
|
||||
assert_equal I18n.t("imports.create.pdf_processing"), flash[:notice]
|
||||
end
|
||||
|
||||
test "guest cannot create statement backed pdf import" do
|
||||
sign_in users(:intro_user)
|
||||
|
||||
assert_no_difference [ "AccountStatement.count", "Import.where(type: 'PdfImport').count" ] do
|
||||
assert_no_enqueued_jobs only: ProcessPdfJob do
|
||||
post imports_url, params: {
|
||||
import: {
|
||||
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
assert_redirected_to new_import_url
|
||||
assert_equal I18n.t("accounts.not_authorized"), flash[:alert]
|
||||
end
|
||||
|
||||
test "duplicate pdf import reuses account statement" do
|
||||
statement = AccountStatement.create_from_upload!(
|
||||
family: @user.family,
|
||||
account: nil,
|
||||
file: uploaded_file(
|
||||
filename: "existing_statement.pdf",
|
||||
content_type: "application/pdf",
|
||||
content: file_fixture("imports/sample_bank_statement.pdf").binread
|
||||
)
|
||||
)
|
||||
|
||||
assert_no_difference "AccountStatement.count" do
|
||||
assert_difference "Import.where(type: 'PdfImport').count", 1 do
|
||||
post imports_url, params: {
|
||||
import: {
|
||||
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
created_import = PdfImport.order(:created_at).last
|
||||
assert_equal statement, created_import.account_statement
|
||||
assert_redirected_to import_url(created_import)
|
||||
end
|
||||
|
||||
test "duplicate pdf import does not enqueue processing twice for reused import" do
|
||||
assert_difference "AccountStatement.count", 1 do
|
||||
assert_difference "Import.where(type: 'PdfImport').count", 1 do
|
||||
assert_enqueued_jobs 1, only: ProcessPdfJob do
|
||||
post imports_url, params: {
|
||||
import: {
|
||||
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
|
||||
}
|
||||
}
|
||||
|
||||
post imports_url, params: {
|
||||
import: {
|
||||
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
created_import = PdfImport.order(:created_at).last
|
||||
assert_equal "importing", created_import.status
|
||||
assert_redirected_to import_url(created_import)
|
||||
end
|
||||
|
||||
test "duplicate pdf import for inaccessible statement does not create import" do
|
||||
AccountStatement.create_from_upload!(
|
||||
family: @user.family,
|
||||
account: accounts(:investment),
|
||||
file: uploaded_file(
|
||||
filename: "existing_statement.pdf",
|
||||
content_type: "application/pdf",
|
||||
content: file_fixture("imports/sample_bank_statement.pdf").binread
|
||||
)
|
||||
)
|
||||
|
||||
sign_in users(:family_member)
|
||||
|
||||
assert_no_difference [ "AccountStatement.count", "Import.where(type: 'PdfImport').count" ] do
|
||||
post imports_url, params: {
|
||||
import: {
|
||||
type: "DocumentImport",
|
||||
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
created_import = Import.order(:created_at).last
|
||||
assert_equal "PdfImport", created_import.type
|
||||
assert_redirected_to import_url(created_import)
|
||||
assert_equal I18n.t("imports.create.pdf_processing"), flash[:notice]
|
||||
assert_redirected_to new_import_url
|
||||
assert_equal I18n.t("imports.create.duplicate_pdf_unavailable"), flash[:alert]
|
||||
end
|
||||
|
||||
test "read only shared user cannot reuse duplicate statement backed pdf import" do
|
||||
AccountStatement.create_from_upload!(
|
||||
family: @user.family,
|
||||
account: accounts(:credit_card),
|
||||
file: uploaded_file(
|
||||
filename: "existing_statement.pdf",
|
||||
content_type: "application/pdf",
|
||||
content: file_fixture("imports/sample_bank_statement.pdf").binread
|
||||
)
|
||||
)
|
||||
|
||||
sign_in users(:family_member)
|
||||
|
||||
assert_no_difference [ "AccountStatement.count", "Import.where(type: 'PdfImport').count" ] do
|
||||
assert_no_enqueued_jobs only: ProcessPdfJob do
|
||||
post imports_url, params: {
|
||||
import: {
|
||||
import_file: file_fixture_upload("imports/sample_bank_statement.pdf", "application/pdf")
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
assert_redirected_to new_import_url
|
||||
assert_equal I18n.t("imports.create.duplicate_pdf_unavailable"), flash[:alert]
|
||||
end
|
||||
|
||||
test "setting statement backed pdf import account links source statement" do
|
||||
statement = AccountStatement.create_from_upload!(
|
||||
family: @user.family,
|
||||
account: nil,
|
||||
file: uploaded_file(
|
||||
filename: "statement.pdf",
|
||||
content_type: "application/pdf",
|
||||
content: file_fixture("imports/sample_bank_statement.pdf").binread
|
||||
)
|
||||
)
|
||||
pdf_import = PdfImport.create_from_statement!(statement: statement)
|
||||
account = accounts(:depository)
|
||||
|
||||
patch import_url(pdf_import), params: { import: { account_id: account.id } }
|
||||
|
||||
assert_redirected_to import_url(pdf_import)
|
||||
assert_equal I18n.t("imports.update.account_saved", default: "Account saved."), flash[:notice]
|
||||
assert_equal account, pdf_import.reload.account
|
||||
assert_equal account, statement.reload.account
|
||||
end
|
||||
|
||||
test "read only shared user cannot link source statement through pdf import account update" do
|
||||
account = accounts(:credit_card)
|
||||
statement = AccountStatement.create_from_upload!(
|
||||
family: @user.family,
|
||||
account: nil,
|
||||
file: uploaded_file(
|
||||
filename: "statement.pdf",
|
||||
content_type: "application/pdf",
|
||||
content: file_fixture("imports/sample_bank_statement.pdf").binread
|
||||
)
|
||||
)
|
||||
pdf_import = PdfImport.create_from_statement!(statement: statement)
|
||||
|
||||
sign_in users(:family_member)
|
||||
patch import_url(pdf_import), params: { import: { account_id: account.id } }
|
||||
|
||||
assert_redirected_to account_url(account)
|
||||
assert_equal I18n.t("accounts.not_authorized"), flash[:alert]
|
||||
assert_nil pdf_import.reload.account
|
||||
assert_nil statement.reload.account
|
||||
end
|
||||
|
||||
test "user cannot view statement backed pdf import for inaccessible statement" do
|
||||
statement = AccountStatement.create_from_upload!(
|
||||
family: @user.family,
|
||||
account: accounts(:investment),
|
||||
file: uploaded_file(
|
||||
filename: "statement.pdf",
|
||||
content_type: "application/pdf",
|
||||
content: file_fixture("imports/sample_bank_statement.pdf").binread
|
||||
)
|
||||
)
|
||||
pdf_import = PdfImport.create_from_statement!(statement: statement)
|
||||
|
||||
sign_in users(:family_member)
|
||||
get import_url(pdf_import)
|
||||
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
test "read only shared user cannot publish statement backed pdf import" do
|
||||
account = accounts(:credit_card)
|
||||
statement = AccountStatement.create_from_upload!(
|
||||
family: @user.family,
|
||||
account: account,
|
||||
file: uploaded_file(
|
||||
filename: "statement.pdf",
|
||||
content_type: "application/pdf",
|
||||
content: file_fixture("imports/sample_bank_statement.pdf").binread
|
||||
)
|
||||
)
|
||||
pdf_import = PdfImport.create_from_statement!(statement: statement)
|
||||
PdfImport.any_instance.expects(:publish_later).never
|
||||
|
||||
sign_in users(:family_member)
|
||||
post publish_import_url(pdf_import)
|
||||
|
||||
assert_redirected_to account_url(account)
|
||||
assert_equal I18n.t("accounts.not_authorized"), flash[:alert]
|
||||
end
|
||||
|
||||
test "rejects unsupported document type for DocumentImport option" do
|
||||
|
||||
@@ -6,6 +6,58 @@ class PlaidItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
sign_in @user = users(:family_admin)
|
||||
end
|
||||
|
||||
test "new redirects with friendly alert when Plaid rejects link_token request for unauthorized products" do
|
||||
# Reproduces issue #1792: the Plaid client account isn't enabled for the
|
||||
# requested products, so Plaid returns an actionable error message. We
|
||||
# should surface that message instead of letting the modal frame render
|
||||
# blank.
|
||||
plaid_provider = mock
|
||||
Provider::Registry.stubs(:plaid_provider_for_region).with(:us).returns(plaid_provider)
|
||||
|
||||
error_body = {
|
||||
"error_code" => "INVALID_PRODUCT",
|
||||
"error_message" => "Your account is not enabled for the following products: [\"investments\" \"liabilities\" \"transactions\"]. To request access, visit https://dashboard.plaid.com/overview/request-products or contact Sales or your Account Manager."
|
||||
}.to_json
|
||||
plaid_provider.expects(:get_link_token).raises(
|
||||
Plaid::ApiError.new(code: 400, response_body: error_body)
|
||||
)
|
||||
|
||||
get new_plaid_item_url(accountable_type: "Investment")
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_match(/not enabled for the following products/, flash[:alert])
|
||||
end
|
||||
|
||||
test "new redirects with generic alert when Plaid raises an unclassified error" do
|
||||
plaid_provider = mock
|
||||
Provider::Registry.stubs(:plaid_provider_for_region).with(:us).returns(plaid_provider)
|
||||
|
||||
plaid_provider.expects(:get_link_token).raises(
|
||||
Plaid::ApiError.new(code: 500, response_body: { "error_code" => "INTERNAL_SERVER_ERROR" }.to_json)
|
||||
)
|
||||
|
||||
get new_plaid_item_url
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_equal I18n.t("plaid_items.errors.link_token_generic"), flash[:alert]
|
||||
end
|
||||
|
||||
test "edit redirects with friendly alert when Plaid rejects update link_token request" do
|
||||
plaid_item = plaid_items(:one)
|
||||
error_body = {
|
||||
"error_code" => "INVALID_PRODUCT",
|
||||
"error_message" => "Your account is not enabled for the following products: [\"transactions\"]."
|
||||
}.to_json
|
||||
PlaidItem.any_instance.expects(:get_update_link_token).raises(
|
||||
Plaid::ApiError.new(code: 400, response_body: error_body)
|
||||
)
|
||||
|
||||
get edit_plaid_item_url(plaid_item)
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_match(/not enabled for the following products/, flash[:alert])
|
||||
end
|
||||
|
||||
test "create" do
|
||||
@plaid_provider = mock
|
||||
Provider::Registry.expects(:plaid_provider_for_region).with("us").returns(@plaid_provider)
|
||||
|
||||
@@ -59,6 +59,25 @@ class Settings::ProfilesControllerTest < ActionDispatch::IntegrationTest
|
||||
assert User.find(@admin.id)
|
||||
end
|
||||
|
||||
test "admin cannot destroy a member who owns accounts in another family" do
|
||||
other_family = families(:empty)
|
||||
legacy_account = other_family.accounts.create!(
|
||||
name: "Legacy savings", balance: 250, currency: "USD",
|
||||
accountable: Depository.new
|
||||
)
|
||||
legacy_account.update_columns(owner_id: @member.id)
|
||||
|
||||
sign_in @admin
|
||||
|
||||
assert_no_difference("User.count") do
|
||||
delete settings_profile_path(user_id: @member)
|
||||
end
|
||||
|
||||
assert_redirected_to settings_profile_path
|
||||
assert_equal I18n.t("settings.profiles.destroy.member_owns_other_family_data"), flash[:alert]
|
||||
assert User.find(@member.id), "user row must be preserved so historical access can be restored"
|
||||
end
|
||||
|
||||
test "admin removing a family member also destroys their invitation" do
|
||||
# Create an invitation for the member
|
||||
invitation = @admin.family.invitations.create!(
|
||||
|
||||
Reference in New Issue
Block a user