ServicesAboutNotesContact Get in touch →
EN FR
Note

Salesforce Person Accounts and Multi-Currency in the Warehouse

Two Salesforce data model quirks that break standard warehouse patterns — Person Accounts that merge Account and Contact, and multi-currency orgs that require exchange rate conversion in dbt.

Planted
dbtbigquerydata modelingdata engineering

Person Accounts and multi-currency are two Salesforce features that require specific dbt handling not covered by the standard dbt packages. Person Accounts are standard in B2C orgs; multi-currency is common in any org with international sales. Both break default warehouse modeling assumptions (Account/Contact separation and single-currency amounts).

Person Accounts

Person Accounts merge the Account and Contact objects into a single record. Instead of a company (Account) with associated people (Contacts), a Person Account is a person who is also an account. This exists because Salesforce was designed for B2B sales (companies buy things), but B2C companies (individuals buy things) needed a way to represent individual customers as first-class entities.

How They Appear in Your Warehouse

When Person Accounts are enabled, the Account table gains Contact-specific fields: FirstName, LastName, Email, Phone. The IsPersonAccount boolean distinguishes person accounts from business accounts. Some fields behave differently depending on this flag:

  • Name on a business account is the company name (“Acme Corp”). On a person account, it’s the concatenation of FirstName and LastName (“Jane Smith”).
  • OwnerId has the same meaning, but the ownership model may differ — person accounts often represent end customers managed by customer success teams rather than sales reps.

The Contact table also has records for person accounts, but they’re linked 1-to-1 with the Account record. Joining Account to Contact for person accounts gives you the same person twice in different tables.

The Modeling Pattern

The simplest approach: create separate base models for person accounts and business accounts.

-- base__salesforce__account_business.sql
SELECT
id AS account__id,
name AS account__name,
industry AS account__industry,
annual_revenue AS account__annual_revenue,
owner_id AS account__owner_id,
parent_id AS account__parent_id,
'business' AS account__type
FROM {{ source('salesforce', 'account') }}
WHERE NOT _fivetran_deleted
AND NOT is_person_account
-- base__salesforce__account_person.sql
SELECT
id AS account__id,
first_name AS account__first_name,
last_name AS account__last_name,
person_email AS account__email,
owner_id AS account__owner_id,
'person' AS account__type
FROM {{ source('salesforce', 'account') }}
WHERE NOT _fivetran_deleted
AND is_person_account

If your downstream consumers need both in a single view (e.g., a customer list that includes both companies and individuals), UNION them in an intermediate model with consistent column naming:

-- int__account_unified.sql
SELECT
account__id,
account__name AS account__display_name,
account__type,
account__owner_id
FROM {{ ref('base__salesforce__account_business') }}
UNION ALL
SELECT
account__id,
CONCAT(account__first_name, ' ', account__last_name) AS account__display_name,
account__type,
account__owner_id
FROM {{ ref('base__salesforce__account_person') }}

Splitting person accounts and business accounts at the base layer — rather than using CASE WHEN is_person_account THEN ... throughout — produces more maintainable models. The field sets differ enough that a single model with conditional column logic becomes hard to read. Merge at the intermediate layer if a unified view is needed downstream.

Watch for in Joins

When joining Opportunities to Accounts, your existing join logic works for both person and business accounts — the AccountId on Opportunity references the Account regardless of type. But if your intermediate models also join Account to Contact (to get contact details), person accounts will create duplicate rows because the person has records in both tables. Filter the Contact join to exclude person account contacts, or use the Account-level person fields instead.

Multi-Currency

Salesforce multi-currency orgs store monetary amounts in the record’s currency, not a single corporate currency. An Opportunity created by a sales rep in Tokyo stores its amount in JPY, while one in Paris stores EUR. Your pipeline reports and revenue dashboards need everything in one currency.

What You Need to Extract

Multi-currency requires two additional objects that your extraction tool needs to sync:

  • CurrencyType — the list of active currencies in the org, with their ISO codes and corporate exchange rates.
  • DatedConversionRate — historical exchange rates by date range, if your org uses “Advanced Currency Management.” Without this, you only get the current rate.

Every monetary record also has a CurrencyIsoCode field indicating its currency. This field is on Opportunity, Account, and any custom object with currency fields.

Conversion in dbt

Convert amounts in dbt using the corporate exchange rate:

-- int__opportunity_currency_normalized.sql
WITH
opportunities AS (
SELECT
opportunity__id,
opportunity__amount,
opportunity__currency_iso_code,
opportunity__close_at
FROM {{ ref('base__salesforce__opportunity') }}
),
record_rates AS (
SELECT
iso_code AS currency__iso_code,
conversion_rate AS currency__record_rate
FROM {{ ref('base__salesforce__currency_type') }}
WHERE is_active
),
corporate_rate AS (
SELECT
iso_code AS currency__iso_code,
conversion_rate AS currency__corporate_rate
FROM {{ ref('base__salesforce__currency_type') }}
WHERE is_corporate
)
SELECT
opportunity__id,
opportunity__amount,
opportunity__currency_iso_code,
opportunity__amount * (
corporate.currency__corporate_rate / record.currency__record_rate
) AS opportunity__amount_corporate_currency
FROM opportunities
LEFT JOIN record_rates AS record
ON opportunities.opportunity__currency_iso_code = record.currency__iso_code
CROSS JOIN corporate_rate AS corporate

The formula amount * (corporate_rate / record_rate) converts from the record’s currency to the corporate currency through a common base. This is the same math Salesforce uses internally.

Advantages of dbt-based currency conversion

Converting currency in dbt rather than relying on Salesforce’s real-time conversion provides:

  1. Version control — the conversion logic is SQL you can review, test, and audit.
  2. Consistency — every model uses the same conversion approach. No risk of some reports using Salesforce’s conversion and others using a different method.
  3. Historical accuracy — if you use DatedConversionRate, you can convert historical amounts at the rate that was in effect when the deal closed, not today’s rate. This matters for financial reporting.
  4. Independence from sync timing — Salesforce’s converted amount fields are formula fields, which means they suffer from the formula field sync blind spot. Computing the conversion in dbt avoids that issue entirely.

Dated vs. Undated Rates

If your org has Advanced Currency Management enabled, use DatedConversionRate for historical accuracy. Join on the rate that was active at the deal’s close date:

LEFT JOIN dated_rates
ON opportunities.opportunity__currency_iso_code = dated_rates.currency__iso_code
AND opportunities.opportunity__close_at >= dated_rates.start_date
AND opportunities.opportunity__close_at < dated_rates.next_start_date

Without Advanced Currency Management, you only have the current rate in CurrencyType. This is fine for current pipeline reporting but produces inaccurate historical analysis when exchange rates have moved significantly.