Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ TIMECAMP_TASK_ID=your_timecamp_task_id_for_redmine_projects_like_170066189
# Optional: skip assigning mandatory tags to a task when more than this many
# mandatory tags would need to be added. Leave empty/unset for no limit.
# TIMECAMP_MAX_MANDATORY_TAGS_TO_ADD=1
# Optional: isolate reads/archives to one integration's external_task_id prefix.
# TIMECAMP_SYNC_EXTERNAL_ID_PREFIX=netsuite_

# AZUREDEVOPS integration
# AZUREDEVOPS_INSTANCES=Company1:https://dev.azure.com/company1:token1,Company2:https://dev.azure.com/company2:token2
Expand Down Expand Up @@ -52,3 +54,14 @@ MONDAY_API_TOKEN=your_monday_api_token
# MONDAY_BOARD_IDS=123456789,987654321
# Optional: comma-separated Monday column titles to export as meandatory_tags.
# MONDAY_MEANDATORY_TAGS=Client,CoE

# NetSuite integration (OAuth 2.0 Client Credentials / M2M)
NETSUITE_ACCOUNT_ID=1234567_SB1
NETSUITE_CLIENT_ID=your_oauth2_integration_client_id
NETSUITE_CERTIFICATE_ID=your_oauth2_m2m_certificate_id
NETSUITE_PRIVATE_KEY_FILE=/absolute/path/to/netsuite-private-key.pem
# NETSUITE_PRIVATE_KEY_PASSPHRASE=optional_private_key_passphrase
# NETSUITE_JWT_ALGORITHM=PS256
# NETSUITE_CONFIG_FILE=netsuite_config.json
# Temporary alternative for a manually obtained OAuth 2.0 bearer token:
# NETSUITE_ACCESS_TOKEN=your_short_lived_access_token
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,34 @@ uv run --with-requirements requirements.txt python export_monday_time_logged.py
uv run --with-requirements requirements.txt python export_monday_time_logged.py --from 2026-06-01 --to 2026-06-18 --column-title "Time Tracked" --include-main-rows
```

### NetSuite ↔ TimeCamp POC

The NetSuite integration synchronizes projects and project tasks into TimeCamp,
then upserts TimeCamp entries as NetSuite `timebill` records. User provisioning
and project-user assignments are intentionally out of scope; employees are read
only to resolve the employee reference required by exported time.

```bash
cp netsuite_config.example.json netsuite_config.json

uv run --env-file .env --with-requirements requirements.txt python fetch_netsuite.py
TIMECAMP_SYNC_EXTERNAL_ID_PREFIX=netsuite_ \
TIMECAMP_SYNC_ACTIONS=tasks,names,estimates,tags,mandatory_tags,archive \
uv run --env-file .env --with-requirements requirements.txt python sync_projects.py

# Dry-run by default.
uv run --env-file .env --with-requirements requirements.txt \
python export_time_entries_netsuite.py --from 2026-08-01 --to 2026-08-03

# Write only after the dry-run has no mapping errors.
uv run --env-file .env --with-requirements requirements.txt \
python export_time_entries_netsuite.py --from 2026-08-01 --to 2026-08-03 --apply
```

See [`docs/netsuite.md`](docs/netsuite.md) for the WCG-specific discovery and
mapping contract. The example CAPEX/OPEX field IDs are placeholders and must be
replaced before an applied export.

### Limiting TimeCamp Sync Actions

By default, `sync_projects.py` runs all actions: creating missing tasks, updating changed
Expand Down
132 changes: 132 additions & 0 deletions docs/netsuite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# NetSuite ↔ TimeCamp POC

## Scope

The integration implements this flow:

1. SuiteQL reads active NetSuite projects and project tasks.
2. `fetch_netsuite.py` writes the common `tasks.json` contract.
3. `sync_projects.py` creates, renames, estimates, tags, and archives the matching
TimeCamp hierarchy.
4. `export_time_entries_netsuite.py` maps TimeCamp entries back to their NetSuite
employee, project, project task/activity, and CAPEX/OPEX classification.
5. The exporter upserts `timebill` records by `timecamp-{entry_id}` external ID.
Re-running a date range updates the same records instead of duplicating them.

User provisioning and project-user assignment are not implemented. The exporter
only reads NetSuite employees and TimeCamp users to resolve the employee required
by a `timebill`. Email is the default key. Ambiguous or missing matches block an
applied export; use `time_export.employee_mapping` for explicit TimeCamp user ID
to NetSuite employee ID overrides.

## Authentication

Use OAuth 2.0 Client Credentials (M2M). NetSuite requires an integration record,
the `REST Web Services` scope, a role with the necessary record permissions, and
a certificate mapping. Configure:

```dotenv
NETSUITE_ACCOUNT_ID=1234567_SB1
NETSUITE_CLIENT_ID=...
NETSUITE_CERTIFICATE_ID=...
NETSUITE_PRIVATE_KEY_FILE=/absolute/path/to/private-key.pem
```

`NETSUITE_ACCESS_TOKEN` accepts a short-lived bearer token for local diagnosis,
but it is not a scheduler credential. Token-based OAuth 1.0 authentication is
deliberately not added: Oracle says that from NetSuite 2027.1 new TBA integrations
for REST web services cannot be created.

## Account discovery before the POC

NetSuite's REST schema is account-specific. Standard and custom fields must be
confirmed against the WCG Records Catalog; guessing them is unsafe. The client
supports the metadata endpoint, and the relevant record types are `job`,
`projecttask`, `employee`, and `timebill`.

Copy `netsuite_config.example.json` to `netsuite_config.json`. Both SuiteQL
queries must use a deterministic `ORDER BY`, because the REST endpoint is paged.
The importer expects these aliases:

| Query | Required aliases | Optional aliases |
| --- | --- | --- |
| `projects` | `id`, `name` | `parent_id`, `capex_opex`, `activity_id`, `is_inactive` |
| `project_tasks` | `id`, `name`, `project_id` | `parent_id`, `capex_opex`, `activity_id`, `estimated_work_hours`, `original_estimate_seconds`, `is_inactive` |
| `employees_query` | `id`, `email` | none |

The example uses standard field candidates, not a claim about WCG's schema.
Replace them when the Records Catalog or a real SuiteQL call proves otherwise.

## CAPEX/OPEX

Alias the WCG project/task classification field to `capex_opex` in SuiteQL. The
importer normalizes it through `classification.value_map`, inherits a missing task
classification from its parent/project, and assigns it under a mandatory TimeCamp
tag list. Unknown non-empty values stop the import instead of corrupting financial
classification. The example sets `classification.required` to `true`, so its
standard query skeleton deliberately cannot be used for synchronization until the
WCG classification field is added to the query.

For export, make an explicit choice in `time_export.classification`:

- `mode: "field"` writes the mapped value to a WCG `timebill` field.
- `mode: "project"` writes no classification field because the selected NetSuite
project is the authoritative classification.
- `mode: "omit"` deliberately drops it. This is appropriate only if WCG confirms
CAPEX/OPEX is irrelevant on individual time records.

The placeholders in the example config intentionally make `field` mode fail until
the real WCG field and list value IDs are entered.

## Activity and project task mapping

Time entered on an imported project task exports both the NetSuite project and
project-task IDs. If WCG uses a service item or another activity reference, alias
its ID as `activity_id` and set `time_export.fields.activity` to the corresponding
`timebill` field. `default_activity_id` is the fallback.

NetSuite custom forms can require extra fields such as approval status, subsidiary,
department, or location. Add invariant values to `time_export.fixed_fields`. Do not
apply an export until the dry-run payload passes against the sandbox metadata and
WCG's approval workflow.

## Commands

```bash
uv run --env-file .env --with-requirements requirements.txt \
python fetch_netsuite.py --config netsuite_config.json --output tasks.json

TIMECAMP_SYNC_EXTERNAL_ID_PREFIX=netsuite_ \
TIMECAMP_SYNC_ACTIONS=tasks,names,estimates,tags,mandatory_tags,archive \
uv run --env-file .env --with-requirements requirements.txt \
python sync_projects.py --input tasks.json

uv run --env-file .env --with-requirements requirements.txt \
python export_time_entries_netsuite.py \
--config netsuite_config.json --tasks tasks.json \
--from 2026-08-01 --to 2026-08-03
```

The exporter is dry-run by default. `--apply` is all-or-nothing for local mapping
validation: any unmapped employee, invalid duration, missing project, or unresolved
CAPEX/OPEX value stops the run before the first NetSuite write. Network failure can
still interrupt a batch, but external-ID upserts make the same command safe to retry.

NetSuite stores `timebill.hours` at minute precision. `duration_rounding` supports
`nearest` (default), `floor`, `ceil`, or `reject`; use `reject` if WCG requires zero
rounding loss.

## Unresolved WCG decisions

- Which standard/custom records are the authoritative project and activity sources?
- What field represents project hierarchy, status, project manager, and CAPEX/OPEX?
- Is CAPEX/OPEX derived from the project or stored on each `timebill`?
- Is `caseTaskEvent` the correct project-task field in WCG's REST metadata?
- Is a service `item`, approval status, subsidiary, department, location, or memo required?
- Should an edited/deleted TimeCamp entry update/delete the NetSuite record, and what
happens after approval or posting closes the accounting period?
- What date window, timezone cutoff, and approval state are eligible for export?

These are specification decisions, not implementation details. The POC should prove
them against a NetSuite sandbox before production credentials or scheduled writes are
allowed.
Loading