> ## Documentation Index
> Fetch the complete documentation index at: https://docs.wegive.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Neon Account Mapping

> Detailed mapping between Neon CRM account objects and WeGive donor records

# Account Object Mapping

This document details the real mapping between Neon CRM account objects and WeGive donor records, based on `NeonIntegration::generateAccountParams()` and `SyncNeon::processAccount()`.

<Warning>
  The donor **account** push and the (dead) **address** push (`syncAddress()`) would be two separate API calls to two separate Neon endpoints (`/accounts` and `/addresses`) if the address push were reachable — but it isn't (known issue). Phone numbers and fax are **not** included in the account push's own address array either, so no phone/fax data reaches Neon via this integration at all today.
</Warning>

## Individual Account Mapping

### Core Identity Fields

| WeGive Field | Neon Field | Notes |
| - | - | - |
| `first_name` | `primaryContact.firstName` | |
| `last_name` | `primaryContact.lastName` | |
| `type = 'individual'` | Account wrapper key | Sent as `individualAccount` |

<Note>
  There is no computed `name` (`firstName + " " + lastName`) sent to Neon — `name` is a WeGive-internal display field, not part of the account push payload.
</Note>

### Contact Information

| WeGive Field | Neon Field |
| - | - |
| `email_1` | `primaryContact.email1` |
| `email_2` | `primaryContact.email2` |
| `email_3` | `primaryContact.email3` |

<Warning>
  Phone/fax are **not** part of the account push at all, and there's nowhere else they'd sync either — `syncAddress()` (the code that would send `mobile_phone`/`home_phone`/`office_phone`/`fax`) is dead code with no caller. See [Address Mapping](/external/onboarding/neon/data-mapping/address). This is a known issue. No phone or fax data reaches Neon via this integration today.
</Warning>

### Communication Preferences

| WeGive Field | Neon Field | Value sent |
| - | - | - |
| `email_notifications` | `consent.email` | The raw WeGive boolean, sent directly — **not** converted to `"GIVEN"`/`"NOT_GIVEN"`/`"UNKNOWN"` strings |
| `sms_notifications` | `consent.sms` | Same — raw boolean |
| — | `consent.dataSharing` | Always `"GIVEN"`, hardcoded |
| — | `consent.mail` | Always `"GIVEN"`, hardcoded |
| — | `consent.phone` | Always `"GIVEN"`, hardcoded |

<Note>
  Neon's API itself may expect `"GIVEN"`/`"NOT_GIVEN"`/`"UNKNOWN"` string enums for consent fields — WeGive sends its own raw boolean value for `email`/`sms` regardless. Whether Neon's API coerces this correctly on its end isn't something WeGive verifies.
</Note>

### Integration Fields

| WeGive Field (stored) | Neon Field |
| - | - |
| `neon_account_id` | `accountId` (sent on update only — omitted on create) |
| `neon_id` | `primaryContact.contactId` |

## Login Block

<Warning>
  Every individual account push includes a `login` block with `username` derived from the donor's name (`preg_replace('/\PL/u', '', $donorProfile->name)` — strips all non-letter Unicode characters) and a **hardcoded, identical password for every donor across every organization**: `'WeGiveTest1234'`. This is a known issue — low priority, since WeGive currently has no active customer on this integration, but a real finding if that ever changes.
</Warning>

## Company Account Mapping

### Core Identity Fields

| WeGive Field | Neon Field |
| - | - |
| `name` | `name` |
| `type = 'company'` | Account wrapper key, sent as `companyAccount` |

<Note>
  There is no separate business-phone/business-email field distinct from the individual structure — company accounts reuse the same `primaryContact` object, with `firstName` sent as `null`. There's also no "company classification"/industry field mapped.
</Note>

### Contact Information

| WeGive Field | Neon Field |
| - | - |
| `email_1` | `primaryContact.email1` |

### Integration Fields

| WeGive Field (stored) | Neon Field |
| - | - |
| `neon_account_id` | `accountId` |

<Note>
  Company accounts don't get a `login` block — the `login` key is only ever added conditionally, but in the actual code both account types share the same `primaryContact` structure and neither has explicit login suppression logic distinguishing individual from company; in practice the login block is present for both account types as generated (see the Login Block warning above).
</Note>

## Address Within the Account Push

The account push's `primaryContact.addresses` array (this is the **only** address data that actually reaches Neon today — see the note above) includes, for the **mailing address**:

| WeGive Field | Neon Field |
| - | - |
| `mailingAddress.address_1` | `addressLine1` |
| `mailingAddress.address_2` | `addressLine2` |
| `mailingAddress.city` | `city` |
| `mailingAddress.zip` | `zipCode` |
| `mailingAddress.state` | `stateProvince.code` (only included if the state is set) |

For **individual** donors only, a second address entry is included if `billingAddress` exists, with the same field set (address lines, city, zip, state) — but **no** `isPrimaryAddress` flag is set on it, and no phone/fax fields are included on either entry.

<Warning>
  There is no `country`/`country.code` field sent on either address entry within the account push — country mapping doesn't exist in `generateAccountParams()`.
</Warning>

## Data Transformations

### Name Processing

* First/last names mapped separately for individuals — no computed display name sent
* Company accounts map WeGive's `name` field directly

### Username Generation

* `preg_replace('/\PL/u', '', $donorProfile->name)` — strips everything that isn't a Unicode letter (spaces, digits, punctuation), keeping only letters. `"John Smith"` becomes `"JohnSmith"`.

### Email

* Up to 3 emails sent (`email_1`/`email_2`/`email_3`) — no format validation is performed by WeGive before sending; any rejection would be a Neon-side API error
* No "primary email" logic beyond `email_1` mapping to `email1` — there's no separate designation

### Communication Preferences

* Sent as raw WeGive booleans (see the warning above) — not translated to Neon's `GIVEN`/`NOT_GIVEN`/`UNKNOWN` enum strings

## Synchronization Behavior

### Account Creation/Update (Push)

1. If `neon_account_id`/`neon_id` already set → `PUT /accounts/{accountId}` (update)
2. Otherwise → `POST /accounts` (create)
3. On create, if Neon returns error code `10012` (duplicate account), search Neon by email and link the existing account instead of retrying create
4. There is no "last modified wins"/timestamp-based conflict resolution — push always sends WeGive's current state; whichever side calls last simply overwrites what Neon has for the mapped fields

### Import from Neon CRM (Pull)

1. `POST /accounts/search` with a `Account Last Modified Date > (last sync - 1 day)` filter, plus `Email 1 NOT_BLANK` and `First Name NOT_BLANK` filters
2. Match to an existing WeGive `User` by exact email
3. No match → create a new `User` (random password, via `Hash::make(Str::random())`) + `Donor` + `Login`
4. If a `Donor` matching the pulled account already exists, only `neon_account_id` and the mailing/billing address are refreshed — first/last name, email, and consent fields are **not** re-imported on subsequent pulls once the donor already exists

<Warning>
  `NeonMappingRule` is **not** consulted anywhere in the pull path above — the field list is fully hardcoded in `SyncNeon::processAccount()`. Any custom account-level mapping rule with `level = 'import'`/`'both'` has no effect on pull. This is a known issue.
</Warning>

## Custom Field Mapping

The default seeded `NeonMappingRule` records for accounts:

| Integration Path | WeGive Path |
| - | - |
| `consent.email` | `email_notifications` |
| `consent.sms` | `sms_notifications` |
| `primaryContact.email1` | `email_1` |
| `primaryContact.email2` | `email_2` |
| `primaryContact.email3` | `email_3` |
| `primaryContact.firstName` | `first_name` |
| `primaryContact.lastName` | `last_name` |

<Warning>
  These are the same fields `generateAccountParams()` already hardcodes directly (see above) — the mapping rules are somewhat redundant with the hardcoded logic for the default set, and only matter for **custom** additions beyond these defaults. Any custom addition only applies on push, per the pull-mapping-rules gap noted above.
</Warning>

### Adding Custom Mappings

```json theme={null}
{
  "integration": "ACCOUNT",
  "integration_path": "customField.value",
  "wegive_path": "custom_field_name",
  "crm": "NEON"
}
```

<Warning>
  There is no dashboard "level: bidirectional" behavior distinct from any other level for Neon in practice — regardless of what `level` is set to, a custom mapping rule is only ever applied on push. This is a known issue.
</Warning>

## Error Handling

<Warning>
  There is no automatic retry, duplicate-merge tooling, or field-validation pre-check beyond the single `10012`-duplicate-error recovery path described above. A failed account push (validation error, missing required field, network failure) simply fails — check Sentry/the integration log for the specific Neon API error response.
</Warning>

## Best Practices

### Data Quality

* Keep donor emails valid and populated — pull skips accounts without an email entirely
* Understand that a donor's first/last name and email won't re-sync from Neon once a `Donor` already exists in WeGive — only the address refreshes on later pulls

### Integration Management

* Check Sentry (not the WeGive dashboard) for account sync failures — there's no dedicated sync-status view for this integration
* Be aware of this known issue if you're considering re-enabling Neon donor-portal login features for any org

## API Examples

### Creating an Individual Account

```http theme={null}
POST /v2/accounts
{
  "individualAccount": {
    "consent": {
      "dataSharing": "GIVEN",
      "email": true,
      "mail": "GIVEN",
      "phone": "GIVEN",
      "sms": false
    },
    "login": {
      "username": "JaneDoe",
      "password": "WeGiveTest1234"
    },
    "noSolicitation": false,
    "origin": {
      "originDetail": "WeGive"
    },
    "primaryContact": {
      "firstName": "Jane",
      "lastName": "Doe",
      "email1": "jane.doe@example.com",
      "addresses": [
        {
          "isPrimaryAddress": true,
          "addressLine1": "456 Oak Avenue",
          "city": "Springfield",
          "stateProvince": {"code": "IL"},
          "zipCode": "62701"
        }
      ]
    }
  }
}
```

<Note>
  This example reflects real payload shape — including the hardcoded literal password, and `consent.email`/`consent.sms` as raw booleans rather than Neon-style enum strings.
</Note>

### Searching Accounts (Pull)

```http theme={null}
POST /v2/accounts/search
{
  "searchFields": [
    {"field": "Account Last Modified Date", "operator": "GREATER_THAN", "value": "2026-01-01"},
    {"field": "Email 1", "operator": "NOT_BLANK"},
    {"field": "First Name", "operator": "NOT_BLANK"}
  ],
  "outputFields": [
    "Account ID", "First Name", "Last Name", "Email 1", "Contact Type", "Company Name",
    "Address Line 1", "Address Line 2", "Address Type", "City", "Country", "State/Province", "Zip Code"
  ],
  "pagination": {"currentPage": 0, "pageSize": 200}
}
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.