---
title: "Finance (NetSuite)"
slug: finance-netsuite
section: Integrations
status: stable
description: "How financial data flows between Globus and NetSuite — system-of-record boundaries, DB-driven data model, event contracts, and phased delivery plan."
last-updated: 2026-10-07
---

# Finance (NetSuite)

How financial data flows between Globus and NetSuite — system-of-record boundaries, DB-driven data model, event contracts, and phased delivery plan.

> **Core Principle: Globus Decides, NetSuite Accounts**
>
> Globus knows what must be charged and when. NetSuite knows if it has been paid and handles all accounting truth. Globus stores references and cached status — never accounting records. All fee types, payment models, and statuses are DB-driven via lookup tables. Nothing is hardcoded.

## System of Record Rules

The boundary between Globus and NetSuite is strict. Violating this boundary creates dual ledgers that drift out of sync — the most expensive kind of technical debt in a finance system.

NetSuite Owns

Invoice documents + line items / Payments + refunds + credit memos / Accounting journals / Revenue recognition / GL account mappings / Tax calculation
Globus Owns

What should be charged (business rules) / Who should pay (applicant / partner / sponsor) / When charges trigger (application milestones) / Fee catalog (DB-driven charge definitions) / Payment model assignment (DDP / Self-Pay / etc.) / Finance visibility UX (cached status from NetSuite)
> **Never Put Accounting Logic in Globus**
>
> Globus triggers financial records — it does not own them. The actual invoice, payment, and refund documents live in NetSuite. Globus stores only the reference ID and a cached status for display performance.

## Architecture Overview

Financial events flow from Globus to NetSuite via the existing event outbox and n8n automation layer. Status updates flow back from NetSuite to Globus via webhooks or scheduled sync. See n8n Automation Layer for the event delivery model.

| System | Role |
| --- | --- |
| Applicant Portal | UI for applicant — displays payment status, invoices, receipts |
| Partner Portal | UI for partners — displays partner invoices, outstanding balances |
| Globus | Operational brain — determines charges, payers, triggers, and business rules |
| NetSuite | Financial brain — creates invoices, processes payments, manages accounting |
| Payment Gateway | Processes card/bank payments (Phase 3+) — Stripe, Adyen, or SuitePayments |

## Phased Delivery Plan

Finance integration is delivered in four phases. Phases 1 and 2 are fully specified below. Phases 3 and 4 are outlined with architecture questions pending CTO review.

> **NetSuite Drives Finance Data**
>
> The phasing reflects a core principle: NetSuite is the system of record for finance. Customer creation, invoicing, and payment recording happen in NetSuite first. Globus receives and caches that data — it never creates its own disconnected financial state. This prevents the dual-ledger drift that occurs when two systems independently track the same data.

## Data Model

> **Everything Is DB-Driven**
>
> All fee types, payment models, charge statuses, and invoice statuses are stored as database lookup records. Nothing is hardcoded as application enums. This allows ops to add new fee types, payment models, and statuses without code changes.

### finance_account_parties

Represents any billable party — applicant, partner organization, or internal sponsor. This generic payer model supports split payments, partner billing, and scholarship waivers without schema changes.

| Field | Type | Notes |
| --- | --- | --- |
| id | PK | Primary key |
| partyType | text (lookup) | APPLICANT / PARTNER_ORG / SPONSOR_INTERNAL — DB-driven lookup |
| applicantId | FK nullable | Links to applicant record when partyType = APPLICANT |
| partnerOrgId | FK nullable | Links to partner_organizations when partyType = PARTNER_ORG |
| brandId | FK nullable | Optional scoping for brand-level billing |
| programId | FK nullable | Optional scoping for program-level billing |
| netsuiteCustomerId | text nullable | Mapped NetSuite customer ID for invoice creation |
| displayName | text | Human-readable name shown in portals |
| email | text nullable | Billing contact email |
| currencyPreference | text nullable | ISO currency code (e.g., USD, EUR) |

### finance_charge_catalog

DB-driven fee "products" — every type of charge the organization can issue. New fee types are added as rows, not code changes. Maps to NetSuite Items for invoice line creation.

| Field | Type | Notes |
| --- | --- | --- |
| id | PK | Primary key |
| chargeKey | text unique | Stable key: PROGRAM_FEE, SEVIS_FEE, INSURANCE_FEE, HOUSING_DEPOSIT, EXTENSION_FEE |
| displayName | text | Display name (e.g., 'Program Fee') |
| defaultAmount | integer | Default charge amount in minor units (cents; e.g. SEVIS_FEE = 22000). Can be overridden per application. |
| defaultCurrency | text | ISO currency code (e.g., USD) |
| taxable | boolean | Flag passed to NetSuite — Globus does not compute tax amounts. NetSuite applies jurisdiction-appropriate tax codes based on this flag. |
| triggerEventKey | text nullable | Application lifecycle event that triggers this charge (e.g., application.accepted, sevis.registration). Used by the finance module to auto-create application_charges when the lifecycle engine fires the matching event. The Finance charge-catalog editor's "Trigger Event" dropdown is populated from the canonical `FEE_TRIGGER_EVENTS` list. |
| isActive | boolean | Soft delete — inactive charges are hidden from new assignments |
| netsuiteItemId | text nullable | Mapped NetSuite Item ID for invoice line creation |

### finance_program_charge_rules

Per-program billing rules that decide which catalog charges are raised for an application. Each row is scoped by program, optional season (null means all seasons of the program), catalog entry, application type (default `enrollment`) and trigger event key (default `application.created`, the only value allowed today), is versioned (unique per scope and `version`), and carries a payer policy (default `APPLICANT`, the only one wired today) and a `status` (`DRAFT`, `ACTIVE` or `INACTIVE`), a `version`, optional `amountOverride` (positive integer, cents) and `currencyOverride` (3 letters), `dueDays`, an effective window (`effectiveFrom` and `effectiveTo`, both nullable) and an optional `notificationTemplateKey` carried into the invoice context (Globus never sends the email). Rules are managed under `/api/admin/programs/:programId/finance/billing-rules`: list and `/resolve` need `fees.view`, `programs.manage` or `seasons.manage`; create, PATCH, `/:ruleId/publish` and `/:ruleId/deactivate` need `fees.trigger`, `programs.manage` or `seasons.manage`. New rules start as `DRAFT` and an `ACTIVE` rule is immutable.

Authoring errors: 422 `SEASON_NOT_IN_PROGRAM` (season belongs to another program), 422 `INVALID_EFFECTIVE_RANGE` (`effectiveFrom` after `effectiveTo`), 422 `INVALID_AMOUNT_OVERRIDE` (zero or negative), 422 `BILLING_RULE_NOT_PUBLISHABLE` (publish of a rule that is not `DRAFT`), 422 `CHARGE_CATALOG_INACTIVE` (catalog entry missing or inactive at publish), 409 `BILLING_RULE_OVERLAP` (another `ACTIVE` rule for the same program, season slot and catalog entry has an overlapping window; the id is in `details.conflictingRuleId`) and 409 `BILLING_RULE_IMMUTABLE` (edit of an `ACTIVE` rule; deactivate it or create a new version). `INVOICE_CUSTOMER_NOT_READY` and `NETSUITE_ITEM_ID_REQUIRED` are the invoice-context read errors described under the n8n lane below.

### finance_rate_cards

*Status: design only, not built. No `finance_rate_cards` table, route or migration exists in the Hub; charge pricing today comes from `finance_charge_catalog.defaultAmount`.*

Scoped pricing overrides — allows per-program, per-season, per-partner, or per-brand pricing that overrides the global finance_charge_catalog defaults. Rate cards use effective date ranges and a priority-based fallback chain to determine the correct price for any given charge at any point in time.

| Field | Type | Notes |
| --- | --- | --- |
| id | PK | Primary key |
| chargeCatalogId | FK | Links to finance_charge_catalog — which charge type this rate card overrides |
| programId | FK nullable | Scopes this rate card to a specific program (null = all programs) |
| seasonId | FK nullable | Scopes this rate card to a specific season (null = all seasons) |
| partnerOrgId | FK nullable | Scopes this rate card to a specific partner organization (null = all partners) |
| brandId | FK nullable | Scopes this rate card to a specific brand (null = all brands) |
| overrideAmount | numeric | The overridden charge amount for this scope |
| overrideCurrency | text | ISO currency code (e.g., USD). Must match catalog currency unless multi-currency is enabled. |
| effectiveStartDate | date | Start of the date range this rate card is effective |
| effectiveEndDate | date nullable | End of the date range (null = no end date, effective indefinitely) |
| priority | integer | Resolution priority — higher values take precedence when multiple rate cards match. Default: 0 |
| isActive | boolean | Soft delete — inactive rate cards are excluded from lookup resolution |
| createdBy | FK | User who created this rate card (audit) |
| approvedBy | FK nullable | User who approved this rate card (required for amounts above a configurable threshold) |
| notes | text nullable | Optional justification or context for the override |

### application_finance_summary

The "fast view" table — used by portals and admin screens for quick finance status display. This single table gives you the "DDP vs normal + paid/not paid" UI without any NetSuite integration.

| Field | Type | Notes |
| --- | --- | --- |
| id | PK | Primary key |
| applicationId | FK | Links to application record |
| paymentModel | text (lookup) | DB-driven: SELF_PAY, DDP, PARTNER_PAYS, SPONSOR_PAYS, SPLIT |
| financeStatus | text (lookup) | DB-driven: NOT_BILLABLE_YET, INVOICED, PARTIALLY_PAID, PAID, OVERDUE, ON_HOLD, WAIVED |
| amountDue | integer | Total charged amount (minor units, base currency) |
| amountPaid | integer | Total amount received (minor units, base currency) |
| amountOutstanding | integer | Outstanding balance (minor units, base currency) |
| baseCurrency | text | Base currency ISO code (e.g., USD) |
| localAmountOutstanding | integer nullable | Approximate display value in local currency, refreshed on each NetSuite sync. Not authoritative — USD amount is the billing truth. |
| localCurrency | text nullable | Applicant's local currency (e.g., CZK, BRL) |
| latestInvoiceRefId | FK nullable | Links to finance_invoice_refs |
| lastSyncedAt | timestamp | Last time status was synced from NetSuite |
| lockReason | text nullable | If locked: withdrawn, no_show, suspended |

### application_charges

Individual charge instances — one application can have multiple charges (program fee, SEVIS fee, insurance, housing deposit). Each charge has its own payer, status, and trigger event.

| Field | Type | Notes |
| --- | --- | --- |
| id | PK | Primary key |
| applicationId | FK | Links to application record |
| chargeCatalogId | FK | Links to finance_charge_catalog |
| payerPartyId | FK | Links to finance_account_parties — who pays this charge |
| amount | numeric | Charge amount (may differ from catalog default) |
| currency | text | ISO currency code |
| quantity | integer | Usually 1 |
| chargeStatus | text (lookup) | DB-driven: PLANNED, READY_TO_INVOICE, INVOICED, PAID, CANCELLED, WAIVED |
| triggerEventKey | text | Application milestone that triggers this charge (e.g., application.accepted) |
| dueDate | date nullable | Payment due date |
| netsuiteInvoiceRef | text nullable | NetSuite invoice reference (if linked per charge) |
| metadata | jsonb | Extensible: installment details, override notes |

### finance_invoice_refs

NetSuite invoice identity + cached status. NetSuite is truth — this is a mirror for portal performance. Updated via webhook or scheduled sync from NetSuite.

| Field | Type | Notes |
| --- | --- | --- |
| id | PK | Primary key |
| source | text | Defaults to NETSUITE (future-proofed for multi-source) |
| sourceInvoiceId | text nullable | NetSuite internal invoice ID |
| externalRef | text unique | Globus-generated reference for idempotent creation |
| accountPartyId | FK | Links to finance_account_parties |
| invoiceNumber | text | Cached NetSuite invoice number (e.g., 29482) |
| invoiceStatus | text (lookup) | DB-driven: DRAFT, OPEN, PAID, VOID, CREDITED, PARTIALLY_PAID |
| amountTotal | integer | Cached total amount (base currency, typically USD) |
| amountDue | integer | Cached outstanding balance (base currency) |
| baseCurrency | text | Base currency ISO code (e.g., USD) |
| localAmount | integer nullable | Amount in applicant's local currency |
| localCurrency | text nullable | Local currency ISO code (e.g., CZK, BRL, THB) |
| exchangeRate | numeric nullable | Exchange rate locked at invoice creation time (not a live rate). Synced from NetSuite and stored per invoice for historical accuracy. |
| dueDate | date | Cached payment due date |
| pdfUrl | text nullable | URL to NetSuite-generated invoice PDF |
| hostedPaymentUrl | text nullable | Full CyberSource Invoicing SuiteApp URL sourced only from NetSuite field `custbody_cs_pymt_inv_paymentlink`. The standard NetSuite `paymentlink` field is prohibited as a fallback. Treat as bearer-style confidential data. |
| paymentLinkFingerprint | text nullable | SHA-256 fingerprint of the normalized full link for correlation, change detection, and email idempotency without logging the token. |
| cyberSourceStatus | text nullable | Raw value of `custbody_cs_pymt_inv_invoicestatus`; `CREATED` is the sandbox-verified payable state for delivery mode `None`. |
| cyberSourceError | text nullable | Raw SuiteApp processing error from `custbody_cs_pymt_inv_errormessage`; tokens and secrets must be redacted before logging or callbacks. |
| lastSyncedAt | timestamp | Last time this record was updated from NetSuite |

### finance_payment_refs

Payment transaction references — cached from NetSuite and/or payment gateway. Used for receipt display and reconciliation audit trail.

| Field | Type | Notes |
| --- | --- | --- |
| id | PK | Primary key |
| applicationId | FK nullable | Links to application record |
| sourcePaymentId | text nullable | Payment transaction ID from the source system |
| sourceInvoiceId | text nullable | Which invoice this payment applies to |
| paymentGateway | text | Payment gateway (default MANUAL) |
| gatewayRef | text nullable | Gateway-specific payment reference |
| amount | integer | Payment amount (minor units) |
| currency | text | ISO currency code |
| paymentStatus | text (lookup) | DB-driven: SUCCEEDED, FAILED, PENDING, REFUNDED |
| paidAt | timestamp | When payment was processed |
| recordedBy | text nullable | User who recorded the payment |
| notes | text nullable | Optional notes |
| rawPayload | jsonb | Full gateway/NetSuite event payload for audit |

### finance_payment_plans

*Status: design only, not built. No `finance_payment_plans` or `finance_payment_plan_schedules` table, installment events (`FINANCE_INSTALLMENT_DUE`, `FINANCE_INSTALLMENT_MISSED`) or missed-payment job exist in code. This note also covers the next table and Missed Installment Handling.*

Payment plan definitions — supports DDP (Deferred Payment Plans), deposit-plus-balance splits, and multi-installment schedules. The DDP payment model references this table to determine the payment structure for an application. Globus owns the schedule; NetSuite executes invoicing per the schedule.

| Field | Type | Notes |
| --- | --- | --- |
| id | PK | Primary key |
| applicationId | FK | Links to application record — one plan per application |
| planType | text (lookup) | DB-driven: DEPOSIT_BALANCE, INSTALLMENTS — determines the split pattern |
| status | text (lookup) | DB-driven: ACTIVE, COMPLETED, CANCELLED, DEFAULTED |
| totalAmount | numeric | Total plan amount (sum of all scheduled installments) |
| currency | text | ISO currency code (e.g., USD) |
| installmentCount | integer | Number of scheduled installments (e.g., 2 for deposit+balance, 4 for quarterly) |
| terms | jsonb | Flexible terms: deposit percentage, installment frequency, grace period days, late fee rules |
| createdBy | FK | User or system that created this plan |
| createdAt | timestamp | When the plan was created |

### finance_payment_plan_schedules

Individual installment rows within a payment plan. Each row represents one scheduled payment with its own due date, amount, and status. NetSuite issues invoices per schedule row. Missed payment detection runs daily against overdue rows.

| Field | Type | Notes |
| --- | --- | --- |
| id | PK | Primary key |
| paymentPlanId | FK | Links to finance_payment_plans |
| installmentNumber | integer | Sequence number (1 = deposit or first installment, 2 = balance or second, etc.) |
| dueDate | date | When this installment is due |
| amount | numeric | Amount due for this installment |
| currency | text | ISO currency code (matches parent plan) |
| status | text (lookup) | DB-driven: PLANNED, INVOICED, PAID, MISSED, CANCELLED |
| netsuiteInvoiceId | text nullable | NetSuite invoice reference for this installment (populated when invoiced) |
| paidAt | timestamp nullable | When payment was confirmed for this installment |
| missedAt | timestamp nullable | When this installment was flagged as missed (dueDate passed without payment) |

> **Globus Owns Payment Schedules; NetSuite Executes Invoicing**
>
> Globus defines the payment plan structure (deposit + balance, installment count, due dates) and stores it in finance_payment_plans and finance_payment_plan_schedules. When each installment’s due date approaches, Globus fires a FINANCE_INSTALLMENT_DUE event. NetSuite creates the invoice for that installment. Payment confirmation flows back via the standard sync pipeline. This separation keeps scheduling logic in Globus (operational) and invoicing in NetSuite (accounting).

Payment Plan Patterns

Deposit + Balance (DDP Default)

Installment 1: Deposit (e.g., 30% of total) — due at application.accepted / Installment 2: Remaining balance — due before program start date (configurable offset, e.g., 30 days prior) / If deposit is not paid by due date, application is placed ON_HOLD and workflow progression is blocked
Multi-Installment

Configurable number of equal or custom-amount installments / Due dates set relative to program start or at fixed calendar intervals / Each installment invoiced independently in NetSuite / Missed installments trigger alerts and can block progression at configured workflow gates
Missed Payment Detection & Alerting

Daily scheduled job checks all PLANNED or INVOICED schedule rows where dueDate < today / Rows past due without payment are marked MISSED with missedAt timestamp / Alert sent to finance ops channel with applicationId, installment details, and days overdue / After configurable grace period (default: 7 days), application financeStatus is updated to OVERDUE / Overdue applications can be configured to block workflow progression via payment gate rules
### Missed Installment Handling

When a scheduled DDP installment passes its due date without confirmed payment, a structured escalation workflow is triggered. This ensures no missed payment goes unnoticed and compliance impact is clearly communicated.

1. Detection

Daily scheduled job checks all PLANNED or INVOICED schedule rows where dueDate < today / Rows past due without payment are marked MISSED with missedAt timestamp / FINANCE_INSTALLMENT_MISSED event is fired with applicationId, installmentNumber, and daysOverdue
2. Applicant Notification

Automated email sent to applicant with missed installment details, amount due, and payment instructions / Portal displays a payment alert banner on the applicant dashboard / If payment model is PARTNER_PAYS, notification is also sent to the partner organization contact
3. Ops Alert & Escalation

Alert sent to finance ops channel with applicationId, installment details, and days overdue / After configurable grace period (default: 7 days), application financeStatus is updated to OVERDUE / After extended grace period (default: 14 days), escalation to finance manager with compliance impact summary / Finance staff can manually override to extend the grace period with an audit-logged reason
4. Compliance Impact

OVERDUE applications can be configured to block workflow progression via payment gate rules / If the applicant is in the Visa & SEVIS phase, DS-2019 issuance is blocked until payment clears / Payment plan status transitions from ACTIVE to DEFAULTED after configurable max missed installments (default: 2) / DEFAULTED payment plan triggers a compliance review queue entry for the application
### Status Enums (DB-Driven)

All status values are stored in a system_lookups table, not hardcoded as application enums. This allows ops to extend statuses without code deployments.

| Lookup Category | Values |
| --- | --- |
| paymentModel | SELF_PAY, DDP, PARTNER_PAYS, SPONSOR_PAYS, SPLIT |
| financeStatus | NOT_BILLABLE_YET, INVOICED, PARTIALLY_PAID, PAID, OVERDUE, ON_HOLD, WAIVED |
| chargeStatus | PLANNED, READY_TO_INVOICE, INVOICED, PAID, CANCELLED, WAIVED |
| invoiceStatus | DRAFT, OPEN, PARTIALLY_PAID, PAID, VOID, CREDITED |
| paymentStatus | SUCCEEDED, FAILED, PENDING, REFUNDED |
| paymentPlanType | DEPOSIT_BALANCE, INSTALLMENTS |
| paymentPlanStatus | ACTIVE, COMPLETED, CANCELLED, DEFAULTED |
| scheduleInstallmentStatus | PLANNED, INVOICED, PAID, MISSED, CANCELLED |
| partyType | APPLICANT, PARTNER_ORG, SPONSOR_INTERNAL |
| gatewayProvider | STRIPE, ADYEN, NETSUITE, MANUAL |

## Payment Models

Payment models determine who pays and how. The model is assigned per application and stored in application_finance_summary.paymentModel. All values are DB-driven lookups.

| Model | Description | Invoice Visible To |
| --- | --- | --- |
| SELF_PAY | Applicant pays program fees directly to sponsor | Applicant Portal |
| DDP | Deferred Payment Plan — applicant pays program fees on a deferred schedule | Applicant Portal |
| PARTNER_PAYS | Partner organization collects and remits on behalf of applicant | Partner Portal |
| SPONSOR_PAYS | Sponsor absorbs cost (scholarship, waiver, internal program) | Globus Admin only |
| SPLIT | Split payment — some charges to applicant, some to partner/sponsor | Both portals (scoped) |

> **Payment Model Drives Portal Visibility**
>
> When paymentModel = PARTNER_PAYS, the Applicant Portal should NOT show invoice details. The Partner Portal shows the invoice instead. Globus enforces this routing based on the payment model value.

## Charge Trigger Events

Each charge type is triggered by a specific application milestone. The mapping between milestones and charges is stored in the finance_charge_catalog table via the triggerEventKey field. This table is DB-driven — ops can change which milestone triggers which charge without code changes.

| Trigger Event | Charge Created | Initial Status | Notes |
| --- | --- | --- | --- |
| application.accepted | PROGRAM_FEE | PLANNED | Primary fee — created when application is approved |
| sevis.registration | SEVIS_FEE | READY_TO_INVOICE | Government fee — due before DS-2019 issuance |
| insurance.enrollment | INSURANCE_FEE | PLANNED | Insurance coverage starts at placement confirmation |
| housing.assigned | HOUSING_DEPOSIT | READY_TO_INVOICE | Only applicable for programs with sponsor-managed housing |
| extension.approved | EXTENSION_FEE | PLANNED | Triggered when program extension is granted |
| renewal.approved | RENEWAL_FEE | READY_TO_INVOICE | Triggered when participant renews for a new program year or cycle |

*The Initial Status column is illustrative. `finance_charge_catalog` stores no initial status; a charge created through `POST /api/applications/:id/payment-requests` defaults to `DUE`, and charges raised by a billing rule start as `INVOICE_PENDING`.*

> **DB-Driven Trigger Mapping**
>
> The trigger-to-charge mapping above is stored as data, not code. Each row in finance_charge_catalog has a triggerEventKey field. When the application lifecycle engine fires that event, the finance module checks the catalog and creates the corresponding application_charges record automatically. New charge types or trigger reassignments require only a database update. See Applicant Lifecycle — Program Extension & Program Renewal Requests for how EXTENSION_FEE and RENEWAL_FEE integrate with the lifecycle workflow, and J-2 Dependent Tracking for J-2 charge details.

### Compliance Payment Gates (Phase 1)

*Status: not enforced today. The Rules Engine SEVIS readiness gate (`rulesEngineReadyForSevisGate.ts`) has no payment or finance rule, and no Rules Engine seed reads `financeStatus`. The table below is the intended design.*

Certain compliance milestones require payment as a hard-block condition before the applicant can progress. These gates are enforced by the Rules Engine and are Phase 1 deliverables — not future-state. In Phase 1, payment status is checked against application_finance_summary.financeStatus only. Per-charge gate checking via individual application_charges statuses is a Phase 2+ capability.

| Compliance Gate | Payment Condition | Enforcement | Phase |
| --- | --- | --- | --- |
| SEVIS Pool Entry | application_finance_summary.financeStatus = PAID. Phase 1 checks the summary-level status only; per-charge granularity (e.g., PROGRAM_FEE) is Phase 2+. | Rules Engine blocks SEVIS_SUBMISSION_INITIATED event until payment condition is met. Applicant cannot enter SEVIS pool without confirmed payment. | Phase 1 |
| DS-2019 Issuance | application_finance_summary.financeStatus = PAID. Phase 1 checks summary-level status; per-charge verification (PROGRAM_FEE, SEVIS_FEE individually) is Phase 2+. | Rules Engine blocks DS-2019 generation until financeStatus is PAID. Prevents issuing immigration documents for unpaid applicants. | Phase 1 |
| Placement Confirmation | Program fee PAID or payment plan ACTIVE with deposit PAID | Soft gate — generates warning to ops staff but does not hard-block. Configurable to hard-block per program. | Phase 1 |
| Program Extension | application_finance_summary.financeStatus = PAID, or program-extension charge PAID/INVOICED if per-charge gating is enabled (configurable per program, Phase 2+) | Rules Engine blocks Program Extension approval until payment condition meets configured threshold. | Phase 1 |

> **Payment Gates Are Phase 1 Rules — Not Future State**
>
> SEVIS pool entry and DS-2019 issuance are compliance-critical milestones. Allowing applicants to reach these gates without confirmed payment creates regulatory risk and financial exposure. The Rules Engine must enforce financeStatus = PAID (or charge-level equivalents) as a hard-block condition from Phase 1. These rules are configured in the Rules Engine and reference finance data via the standard query interface. See also: Lifecycle Transition Triggers for how these payment gates integrate with phase transitions.

> **Phase 1 vs Phase 2+ Gate Checking**
>
> In Phase 1, all payment gates check the aggregate application_finance_summary.financeStatus field only. Per-charge gate checking via individual application_charges statuses is a Phase 2+ capability. Phase 1 gates answer "is this application paid?" at the summary level. Phase 2+ gates can answer "is this specific charge paid?" at the line-item level.

## Rate Card Management

*Status: design only, not built. No rate card model, date-overlap validation, rollover reminders or `finance.rate_card.*` permissions exist in code; finance routes use `fees.*` permission keys (for example `fees.view`, `fees.trigger`). The subsections below describe the intended design.*

Rate cards allow scoped pricing overrides beyond the global defaults in finance_charge_catalog. A rate card can set a different price for a specific program, season, partner, or brand — or any combination. The system resolves the correct price using a priority-based fallback chain.

### Rate Card Lookup Resolution

When a charge is created for an application, the system determines the correct amount by checking for matching rate cards in priority order. The first match wins. If no rate card matches, the catalog default applies.

| Priority | Scope | Description |
| --- | --- | --- |
| 1 (Highest) | Partner + Program + Season | Most specific — a rate card scoped to a specific partner, program, and season combination |
| 2 | Partner + Program | Partner-specific pricing for a program regardless of season |
| 3 | Season + Program | Season-specific pricing for a program (e.g., Summer 2026 has different rates) |
| 4 | Program Override | Program-level pricing that differs from the catalog default |
| 5 | Brand Override | Brand-level pricing that applies across all programs under that brand |
| 6 (Lowest) | Catalog Default | The defaultAmount from finance_charge_catalog — used when no rate card matches |

Resolution is evaluated at charge creation time — the resolved amount is stored on application_charges.amount / If multiple rate cards match at the same priority level, the one with the highest priority field value wins / Effective date filtering applies: only rate cards where effectiveStartDate <= today and (effectiveEndDate is null or effectiveEndDate >= today) are considered / The resolution result (which rate card was used, or 'catalog default') is stored in application_charges.metadata for audit
### Rate Card RBAC

Rate card management is restricted to authorized finance staff. These permissions extend the existing Finance RBAC model.

| Permission | Who Has It | What It Controls |
| --- | --- | --- |
| finance.rate_card.view | Finance staff, Program managers (scoped) | View rate cards and their effective date ranges, amounts, and scoping |
| finance.rate_card.manage | Finance admin only | Create, edit, and deactivate rate cards. Includes setting effective date ranges and priority. |
| finance.rate_card.approve | Finance admin (separation of duties) | Approve rate cards above configurable amount thresholds. A user with only manage permission cannot self-approve their own rate cards — a separate approver is required. |

### Rate Card Audit Trail

All rate card mutations are audit-logged. Rate card changes directly affect billing amounts, making the audit trail essential for financial compliance and dispute resolution.

| Action | Audit Record Contains |
| --- | --- |
| Rate card created | rateCardId, chargeCatalogId, scope (programId/seasonId/partnerOrgId/brandId), overrideAmount, effectiveDates, createdBy, timestamp |
| Rate card amount changed | rateCardId, old overrideAmount, new overrideAmount, actor, timestamp, change reason |
| Rate card dates changed | rateCardId, old effectiveStartDate/effectiveEndDate, new dates, actor, timestamp |
| Rate card deactivated | rateCardId, actor, timestamp, deactivation reason |
| Rate card approved | rateCardId, approvedBy, timestamp, approval notes |

### Season Rollover Reminders

When a new season is created, finance staff must review and confirm pricing. The system proactively flags programs that are still using the previous season’s rate cards.

When a new season record is created, the system scans all active rate cards scoped to the previous season / Programs with previous-season rate cards but no new-season rate cards are flagged as 'Pricing Review Needed' / A notification is sent to all users with finance.rate_card.manage permission listing the flagged programs / Flagged programs appear in a 'Season Rollover' dashboard widget with: program name, previous rate, and a 'Copy & Update' action / 'Copy & Update' clones the previous season's rate card with the new seasonId and lets the finance admin adjust the amount before saving / Until the rate card is confirmed for the new season, charges for that program fall back to the catalog default (not the expired season rate) / Season rollover reminders are re-sent weekly until all flagged programs have confirmed rate cards or are explicitly dismissed
### Effective Date Validation

Rate cards with overlapping effective date ranges for the same scope would create ambiguity in price resolution. The system enforces validation rules to prevent overlaps.

Two rate cards for the same chargeCatalogId + programId + seasonId + partnerOrgId + brandId combination cannot have overlapping date ranges / Overlap check: if an existing rate card has effectiveStartDate <= newCard.effectiveEndDate AND effectiveEndDate >= newCard.effectiveStartDate (or either end date is null), the overlap is detected / On overlap detection, the system rejects the new rate card with an error message identifying the conflicting card / Finance admin can resolve overlaps by: adjusting the new card's dates, deactivating the conflicting card, or explicitly ending the old card's effective period / Rate cards with effectiveEndDate = null (indefinite) are treated as extending to infinity for overlap checks / Validation runs at create and update time — not retroactively. Existing overlaps from data migration are flagged in a one-time reconciliation report
## Phase 1 — NetSuite Integration (MVP)

> **Phase 1 Goal: NetSuite as the Source of Truth from Day One**
>
> Connect Globus to NetSuite immediately. Customer records, invoices, and payment status originate in NetSuite and flow into Globus. Globus caches this data for display and operational use but never creates its own disconnected financial state. This prevents dual-ledger drift from the start.

> **Customer Record Created When First Payment Is Needed**
>
> The NetSuite customer record is created at the point when the first payment is needed — not when the applicant first enters the system. Once the customer exists in NetSuite, NetSuite handles invoice creation through its own internal automations. Globus then pulls the invoice back across the integration. The sequence is simple: create customer → NetSuite issues invoice → pull invoice back to Globus.

### Phase 1 Scope & Integration

Establish NetSuite integration and build Globus cache tables for finance data.

| Deliverable | Description |
| --- | --- |
| NetSuite API connection | Authenticated integration with NetSuite REST/SuiteTalk API |
| Customer creation at first payment | When first payment is needed (e.g. the program's configured acceptance stage), create NetSuite customer. NetSuite then handles invoicing via its own automations. |
| Date fields for invoicing | Send program dates (start, end, placement, program-extension end date) so NetSuite can schedule future invoices based on milestones |
| finance_account_parties table | Cache payer identity (applicant/partner/sponsor) with NetSuite customer ID reference |
| finance_invoice_refs table | Cache invoice identity, status, amounts (USD + local currency), PDF URL, CyberSource payment link/status/error, and a redacted link fingerprint from NetSuite |
| application_finance_summary table | Per-application cached finance status — derived from NetSuite invoice data |
| finance_charge_catalog table | DB-driven fee type definitions mapped to NetSuite items |
| finance_rate_cards table | Scoped pricing overrides — per-program, per-season, per-partner pricing with effective date ranges, priority-based fallback, and audit trail |
| finance_payment_plans + schedules tables | Payment plan definitions and installment schedules for DDP. Globus owns the schedule; NetSuite executes invoicing per schedule. Includes missed payment detection. |
| Payment gates (compliance) | Rules Engine gates that block SEVIS pool entry and DS-2019 issuance until mandatory charges are PAID. Phase 1 hard-block rules. |
| Payment step type (Workflow Studio) | Blocking workflow step that gates applicant progression until payment clears. Assumes P11 will be built. |
| Multi-currency support | Store and display amounts in USD and local currency. Currency code and exchange rate synced from NetSuite. |
| system_lookups table | All status/model enum values (DB-driven, not hardcoded) |
| NetSuite → Globus sync | Webhook or scheduled job pulls invoice status and payment status into Globus cache |

Exit Criterion

Customer records sync between Globus and NetSuite. NetSuite issues invoices and Globus pulls them back. For eligible invoices, the CyberSource SuiteApp payment link is synchronized from the saved NetSuite transaction and can be delivered only after the persisted invoice passes the payable-link gates. Invoices display in USD and local currency. Payment status flows from NetSuite into Globus cache within 5 minutes.

### Phase 1 Data Flow

All finance data originates in NetSuite. Globus caches and displays.

### Date Fields for NetSuite Invoicing

Finance needs specific dates from Globus to determine when to issue future invoices. These dates are sent to NetSuite during customer creation and updated as the application progresses.

| Date Field | Source in Globus | Used By NetSuite For |
| --- | --- | --- |
| programStartDate | Application → placement confirmation | Schedule program fee invoice, insurance start date |
| programEndDate | Application → program assignment | Determine Program Extension invoice timing, insurance end date |
| applicationAcceptedDate | Application → status change | Trigger initial invoice (first payment needed = customer creation) |
| placementConfirmedDate | Placement → host assignment | Housing deposit invoice, placement fee invoice |
| sevisSubmissionDate | SEVIS → submission event | SEVIS fee invoice timing |
| extensionApprovedDate | Program Extension → approval event | Program Extension fee invoice |
| expectedArrivalDate | Application → travel details | Insurance coverage start, orientation scheduling |

> **Dates Drive NetSuite Automations**
>
> These dates are not just display fields — they are the inputs NetSuite uses to schedule future invoices automatically. When Globus updates a date (e.g., placement confirmed), it pushes that update to NetSuite so finance automations can react accordingly.

### Phase 1 Admin Display

Globus Admin View

Application → Finance Tab

Payment Model: DDP

Finance Status: Paid (synced from NetSuite)

Amount Due: $0.00 (CZK 0)

Amount Paid: $1,200.00 (CZK 27,600)

Currency: USD · Local: CZK (rate: 23.0)

NetSuite Invoice: INV-29482

NetSuite Customer: CUST-18827

All finance data cached from NetSuite. Status reflects last sync. Staleness indicator shown if data is older than configured threshold.

### Phase 1 Ops Workflow (Fallback)

While Phase 1 establishes automated sync from NetSuite, manual override workflows remain available for edge cases, corrections, and situations where sync has not yet caught up:

Manual Update (Single Application)

Staff opens Application → Finance Tab in Globus admin / Sets payment model (DDP, SELF_PAY, PARTNER_PAYS, etc.) from DB-driven dropdown / Sets finance status (INVOICED, PAID, OVERDUE, etc.) from DB-driven dropdown / Enters amount due / amount paid manually / Optionally enters NetSuite invoice reference ID (text field, no API validation) / Save triggers audit log entry with old/new values, actor, and timestamp
Bulk Import (Multiple Applications)

*Status: the built endpoint is `POST /api/finance/bulk-import` (permission `fees.trigger`). It takes a JSON `rows` array (at least one row), processes rows synchronously and returns updated and created counts plus per-row error strings. There is no CSV upload, BullMQ queue, row-count limit, idempotency key or the error codes listed below; the rest of this subsection is the intended design.*

Finance bulk import follows the shared bulk operations infrastructure defined in Partner Governance — Bulk Operations Architecture. The same async lifecycle, BullMQ queue, partial success model, error reporting, and idempotency key mechanism apply.

Async Processing Lifecycle

Terminal states: Complete (all rows processed), Partial (some rows failed), Failed (system error)

Upload Steps

Staff downloads CSV template with columns: applicationId, paymentModel, financeStatus, amountDue, amountPaid, invoiceRef / Staff fills in finance data from NetSuite reports or internal spreadsheets / Staff uploads CSV via Globus admin → Finance → Bulk Import / File is persisted to object storage, assigned a job ID and idempotency key, and enqueued to the BullMQ bulk processing queue / System performs deep validation row-by-row (see validation rules below) / Valid rows are processed: finance fields updated, audit log entries created with source = 'bulk_import' / Error report generated for any failed rows with row number, field name, error code, and human-readable message / Staff reviews results on the upload history page with downloadable error report
> **Decision: Deep Validation in Phase 1**
>
> Phase 1 bulk import uses deep validation: format checks + lookup validation + charge catalog cross-check + staleness detection against last NetSuite sync timestamp. Finance errors are costly and downstream reconciliation failures are harder to fix than upfront validation complexity.

Validation Rules

*Status: design target, not built. The implemented `POST /api/finance/bulk-import` takes a JSON `rows` array and applies each row as-is; a row is rejected only when `applicationId` is missing or processing throws. The charge catalog cross-check, staleness detection and duplicate-row detection below are not implemented, and the `AMOUNT_DEVIATION`, `STALE_DATA` and `DUPLICATE_ROW` error codes do not exist.*

| Check | Rule | Error Code |
| --- | --- | --- |
| Format | CSV encoding, header row matches template, no empty required fields | FORMAT_ERROR |
| Application lookup | applicationId exists in the system and is accessible to the uploader's RBAC scope | APP_NOT_FOUND |
| Lookup validation | paymentModel and financeStatus values match system_lookups entries | INVALID_LOOKUP |
| Charge catalog cross-check | amountDue is validated against charge catalog defaults for the application's program + charge type; flagged if deviation exceeds configured threshold | AMOUNT_DEVIATION |
| Staleness detection | Row is flagged if the application's finance data was updated by NetSuite sync after the CSV was generated (compares lastNetSuiteSyncAt vs. upload timestamp) | STALE_DATA |
| Duplicate row detection | Multiple rows targeting the same applicationId within the same CSV are flagged | DUPLICATE_ROW |

Rate Limiting & Constraints

| Constraint | Limit | Rationale |
| --- | --- | --- |
| Max rows per file | 1,000 | Finance CSVs are simpler than applicant imports; higher limit is safe |
| Max concurrent uploads | 1 per user | Prevents race conditions on finance field updates |
| Max file size | 5 MB | Prevents memory pressure during parsing |
| Upload cooldown | 30 seconds | Prevents rapid-fire submissions that bypass validation feedback |
| Daily upload limit | 20 uploads / 24 hours | Finance imports are higher volume than partner imports; escalation available |
| Global queue depth | 100 pending jobs | Shared with partner bulk queue; returns 429 when full |

Error Report & Idempotency

Error reports are downloadable CSVs with columns: rowNumber, applicationId, field, errorCode, message, originalValue / Each upload is assigned an idempotency key (SHA-256 of normalized file content + uploader ID) / Re-uploading an identical file returns the original job result instead of creating a duplicate; the cooldown window prevents rapid resubmission / Partial success is the default — valid rows are always processed even if other rows fail validation
> **Manual Overrides Are Audited**
>
> When staff manually update finance data, the audit trail captures the override with source = "manual". If the automated NetSuite sync later sends a conflicting update, the reconciliation rules apply: NetSuite wins for accounting data (amounts, payment status), Globus wins for operational data (payment model).

## Phase 2 — Invoice Visibility

*Status: not built. Only BackOfficePrototype staff routes exist (for example `GET /api/applications/:id/invoices` in `server/routes/finance.ts`); there is no finance panel, invoice list or invoice PDF download in the applicant portal (PPPToo) or the Host Portal.*

> **Phase 2 Goal: Portals Display Finance Data from NetSuite**
>
> With the NetSuite integration established in Phase 1, Phase 2 surfaces that data to end users. Applicants and partners see invoice status, amounts, and can download invoice PDFs — all from cached NetSuite data. Payment model drives what each portal user sees.

### Phase 2 Scope & Deliverables

Portal UI and API endpoints that display cached NetSuite finance data.

| Deliverable | Description |
| --- | --- |
| Applicant portal finance panel | Payment status, amount due, payment model display — only shown when paymentModel ≠ PARTNER_PAYS |
| Applicant invoice list | List of invoices with status, amounts, and due dates from finance_invoice_refs cache |
| Invoice PDF download | Proxy or redirect to NetSuite-generated PDF URL |
| Partner portal finance panel | Partner invoice status and outstanding balance — shown when paymentModel = PARTNER_PAYS or SPLIT |
| Portal API endpoints | GET /applications/:id/invoices and GET /applications/:id/finance-summary |
| application_charges table | Per-charge tracking with payer, amount, trigger event — drives itemized portal display |

Exit Criterion

Applicants and partners can see payment status and invoice details in their portals. Invoice PDFs downloadable. All data sourced from NetSuite via Globus cache.

### Event Contracts (Phase 1 & 2)

These event contracts are established in Phase 1 (integration layer) and consumed by Phase 2 (portal display).

FINANCE_CUSTOMER_CREATED 
eventId: "evt_100"

entityType: "applicant" | "partner"

entityId: "app_789"

name: "Jane Smith"

email: "jane@example.com"

brandKey: "ALLIANCE_ABROAD"

currency: "CZK" // applicant's local currency

programDates:

programStartDate: "2026-06-01",

programEndDate: "2026-09-30",

applicationAcceptedDate: "2026-03-15",

expectedArrivalDate: "2026-05-28"

Triggered when first payment is needed. NetSuite creates customer, uses programDates to schedule invoice automations. Returns netsuiteCustomerId stored in finance_account_parties.

**As built (BackOfficePrototype #1682, #1691).** The request event is named `finance.customer_requested` (entityType `applicant`; `applicant.finance_linked` is the confirmation side), not `FINANCE_CUSTOMER_CREATED`, and it is emitted by `emitFinanceCustomerRequested()` in `server/services/eventEmitter.ts` with this payload: `applicantId`, `externalRef` (deterministic `CUST-REQ:<applicantId>`), `financeAccountPartyId` (nullable), `applicant` (`id`, `firstName`, `lastName`, `email`), `brand` (`id`, `name`), `currency` (the finance account party's preferred currency, default `USD`), `programDates` (`startDate` / `endDate` taken from the application's season; nullable) and `requestedAt`. The payload does not carry `applicationAcceptedDate` or `expectedArrivalDate`. The dedupe key is the permanent per-applicant key `finance.customer_requested:<applicantId>:permanent`, so one request is ever emitted per applicant.

The trigger is configuration-driven: `server/services/financeCustomerSync.ts` runs on every `application.stage_transitioned` event and emits the request only when the stage the application just entered is the program's acceptance stage in the `finance_customer_sync_triggers` table (active row matching `programId` + `acceptanceStageKey`) and the applicant's `finance_account_parties` row does not already carry a `netsuiteCustomerId`. There is no hard-coded APPLICATION_ACCEPTED stage. n8n creates the NetSuite customer and calls back the existing `PUT /api/integrations/applicants/:applicantId/finance-link`, which stores `netsuiteCustomerId` and emits `applicant.finance_linked`.

FINANCE_INVOICE_REQUESTED 
eventId: "evt_123"

applicationId: "app_789"

externalRef: "GLOBUS-INV-app_789-1" // idempotent

billTo:

lines: []

dueDate: "2026-04-15"

currency: "USD"

Idempotency: externalRef must be unique. NetSuite creation is safe to retry.

**As built.** The event is named `finance.invoice_requested` (not `FINANCE_INVOICE_REQUESTED`); BackOfficePrototype emits it from program charge rules and from `POST /api/applications/:id/invoice-requests`. That route needs the `finance.invoices.create` API-key scope or session permission `fees.trigger`, an `Idempotency-Key` header (400 without it) and an application inside the caller scope (404 if unknown). It returns 202 with `requestId`, `status` `QUEUED` and a `statusUrl`. An `externalRef` that already exists as an invoice returns 422, one linked to a different application returns 409, and 503 means the event dispatcher is not configured. `GET /api/applications/:id/invoice-requests/:externalRef` (`finance.invoices.read` scope plus `fees.view`) returns the request status (`QUEUED`, `IN_PROGRESS`, `PAYMENT_LINK_PENDING`, `PAYMENT_LINK_READY` or `FAILED`) and the persisted invoice summary; the payment link is included only at `PAYMENT_LINK_READY`, and the response is not cacheable.

netsuite.invoice.updated
netsuiteInvoiceId: "INV_29482"

externalRef: "GLOBUS-INV-app_789-1"

status: "OPEN"

invoiceNumber: "29482"

amountTotal: 1200, amountDue: 1200

currency: "USD"

localAmount: 27600, localCurrency: "CZK"

exchangeRate: 23.0

pdfUrl: "https://..."

paymentLink: "https://APPROVED_CYBERSOURCE_HOST/ebc2/invoicing/payInvoice/REDACTED" // optional while pending

cybersourceInvoiceStatus: "CREATED" // raw NetSuite SuiteApp field

cybersourceErrorMessage: null

Globus updates: finance_invoice_refs, application_finance_summary, related application_charges. **As built:** the inbound NetSuite events are named `netsuite.invoice.created` and `netsuite.invoice.updated` (not `NETSUITE_INVOICE_UPDATED`) and are mapped by the `[Globus] Handler: NetSuite Invoice Domain` n8n workflow. Omitted CyberSource fields do not clear a previously stored valid link; an explicit remediation contract is required to clear one. The link path token must never appear in logs, callbacks, screenshots, fixtures, or documentation.

NETSUITE_PAYMENT_APPLIED 
netsuiteInvoiceId: "INV_29482"

netsuitePaymentId: "PAY_9911"

status: "SUCCEEDED"

amountApplied: 1200

paidAt: "2026-03-20T10:22:00Z"

**As built.** There is no `NETSUITE_PAYMENT_APPLIED` event. Payments arrive through `POST /api/integrations/n8n/netsuite/payment-sync`, whose body uses `sourcePaymentId` (required), `applicationId`, `externalRef`, `sourceInvoiceId`, `paymentGateway`, `gatewayRef`, `amount`, `currency`, `paymentStatus`, `paidAt` and `notes`.

### Phase 2 Portal Display

Applicant Portal

Payment Status

Program Fee: $1,200 (CZK 27,600)

Status: Awaiting Payment

Payment Type: DDP

My Invoices

Invoice #29482

Program Fee — $1,200 (CZK 27,600)

Due: 15 May 2026

[Download Invoice]

Invoice #29501

SEVIS Fee — $220 (CZK 5,060)

Status: Paid

[Download Receipt]

Only shown when paymentModel ≠ PARTNER_PAYS. All data from NetSuite cache.

Partner Portal

Placement Fee Status

Invoice: Pending

Amount: $600

[Download Invoice]

Only shown when paymentModel = PARTNER_PAYS or SPLIT

## Phase 3 — CyberSource Pay-by-Link Delivery

> **Verified implementation boundary**
>
> NetSuite owns invoice accounting and the installed CyberSource Invoicing SuiteApp owns hosted-link creation. Globus owns the business trigger, invoice reference cache, notification claim, and audit state. n8n performs orchestration. SendGrid sends the applicant email. Globus and n8n never collect card data or construct a gateway URL.

The July 15, 2026 sandbox onboarding proved the exact NetSuite source fields and the required configuration prerequisites. The complete evidence and troubleshooting chronology are recorded in [CyberSource Pay-by-Link Sandbox Onboarding Record](./cybersource-pay-by-link-onboarding).

### Trigger and established workflow path

*Status: the design below was not built as written. As built, the invoice trigger is the `finance.invoice_requested` event (emitted by BackOfficePrototype from program charge rules and by `POST /api/applications/:id/invoice-requests`). The `[Globus] Dispatcher: Event Router` (dev `HYPetFZNQ3fuKhCk`, qa/prod `qswInNZQJ030bN1P`) routes it by its `finance` domain to `[Globus] Handler: Finance Invoice Request` (qa/prod `CjHENED63YSz8GCI`), which contains separate branches for `finance.invoice_requested` and `finance.payment_import_requested`. `[Globus] Handler: Application Domain` (`albIaWgKqO3ta571`) has no invoice branch, and the ids `PjtxHxNrtgCZjc2b` and `3JmDhlns2mAyzBnj` quoted below are not in `workflows/id-map.json`.*

The invoice branch is additive to the existing event architecture. The combined create path commits the applicant and application transaction, then invokes `emitApplicantCreatedSafe(...)` and `emitApplicationCreatedSafe(...)`. Both events exist, but the invoice design must not join them, wait for one from the other, or depend on cross-event delivery order. The invoice trigger is the existing `application.created` event, carried in the dispatcher's canonical normalized envelope: `{ id, eventId, domain, eventType, payload, receivedAt }`.

The canonical path is **Globus event outbox → `[Globus] Dispatcher: Event Router` (`PjtxHxNrtgCZjc2b`) → existing `application` route → `[Globus] Handler: Application Domain` (`3JmDhlns2mAyzBnj`) → new dedicated NetSuite invoice-create delivery workflow**. The Application Domain handler adds an `eventType === 'application.created'` branch while preserving its existing HubSpot delivery chain. The dispatcher receives no new domain, envelope, or event type. Invoice failure must be reported explicitly and must not be mislabeled as successful HubSpot delivery.

### Raising the first invoice

The existing development workflow is not invoice-ready: `applicant.created` currently creates or resolves a NetSuite **contact** and stores that contact internal ID as `netsuiteCustomerId`. A contact is not the invoice-capable entity required by this flow. Before invoice creation, the new delivery workflow must resolve or create the correct NetSuite customer/entity under the invoice subsidiary and persist only that entity's internal ID as `netsuiteCustomerId`. Customer readiness is determined from the `application.created` payload and/or an authoritative lookup; it is not inferred by waiting for `applicant.created`.

Globus decides that the charge is due and supplies the application, payer, catalog/rate-card result, amount, currency, NetSuite item mapping, dates, and an idempotent external reference. n8n creates the NetSuite invoice with NetSuite/CyberSource email delivery disabled. The invoice must select the environment's approved AAG subsidiary/profile/MID configuration; credential values and accounting IDs remain environment configuration rather than workflow constants.

**As built (development `[Globus] Handler: Finance Invoice Request`, `Build Invoice Request` node):** the handler accepts only `USD` and `GBP` invoices (any other currency is rejected before NetSuite is called) and selects the NetSuite currency, subsidiary and CyberSource merchant profile from a per-currency map: USD → currency `1`, subsidiary `2`, CyberSource profile (`custbody_cs_pymt_inv_midaccount`) `2`; GBP → currency `2`, subsidiary `30`, CyberSource profile `3` (the CyberSourceGBPWP profile, used for GBP Wild Packs invoices). The profile ID is taken from the currency map, not a static value, and the workflow contract validator (`scripts/validate_workflow_contracts.py`) fails the build if either rule is removed (n8n-workflows #94).

| Step | Owner | Required result |
| --- | --- | --- |
| 1. Trigger | Globus | Reuse `application.created`; derive one deterministic `externalRef` from the application and charge identity. |
| 2. Route | Dispatcher + Application Domain handler | Preserve the canonical envelope and `application` route; execute the dedicated invoice-create delivery branch only for `application.created`. |
| 3. Customer/entity | n8n + NetSuite | Resolve or create an invoice-capable customer/entity; never substitute a contact ID. |
| 4. Invoice save | n8n + NetSuite | `POST /services/rest/record/v1/invoice`; require HTTP 204 and capture the saved record URL from the `Location` response header. |
| 5. Invoice identity | n8n | Parse the NetSuite invoice internal ID from the terminal segment of `Location`; do not expect a JSON create-response body. |
| 6. Post-save read | n8n + NetSuite | `GET /services/rest/record/v1/invoice/{internalId}?fields=...`; read the CyberSource fields from the HTTP 200 JSON body. |
| 7. Bounded wait | n8n | Stop on a valid payable link, an explicit SuiteApp error, or the configured timeout; default maximum elapsed time is 60 seconds. |
| 8. Synchronize | n8n + Globus | Upsert invoice identity and CyberSource fields through the existing invoice-sync route. |
| 9. Claim and send | Globus + n8n + SendGrid | Claim one semantic email, call SendGrid, and mark it accepted only after HTTP 202. |
| 10. Callback | n8n + Globus | Record source-event and notification outcomes without exposing the payment token. |

### Exact call and response that provide the payment link

The payment link is **not** returned by the invoice-create response. Oracle specifies that a successful REST record create returns **HTTP 204 No Content** and identifies the new record in the `Location` response header.[1] The repository already uses this pattern for NetSuite contact creation by reading `location` and parsing its final path segment.

```http
POST /services/rest/record/v1/invoice
Content-Type: application/json

{ "entity": { "id": "CUSTOMER_ID" }, "item": { "items": [/* mapped charge */] }, "subsidiary": { "id": "ENV_SUBSIDIARY_ID" }, "externalId": "DETERMINISTIC_EXTERNAL_REF" }
```

```http
HTTP/1.1 204 No Content
Location: https://ACCOUNT.suitetalk.api.netsuite.com/services/rest/record/v1/invoice/INTERNAL_ID
```

The `POST` response provides only the saved invoice identity. n8n must then call the saved-record endpoint. Oracle documents `fields=` as the selective body-field retrieval mechanism and returns the selected fields in an HTTP 200 JSON body.[2]

```http
GET /services/rest/record/v1/invoice/INTERNAL_ID?fields=id,tranId,custbody_globus_application_id,custbody_cs_pymt_inv_paymentlink,custbody_cs_pymt_inv_invoicestatus,custbody_cs_pymt_inv_errormessage
```

```json
{
  "id": "INTERNAL_ID",
  "tranId": "INVOICE_NUMBER",
  "custbody_globus_application_id": "APPLICATION_ID",
  "custbody_cs_pymt_inv_paymentlink": "https://APPROVED_CYBERSOURCE_HOST/REDACTED_PATH",
  "custbody_cs_pymt_inv_invoicestatus": "CREATED",
  "custbody_cs_pymt_inv_errormessage": ""
}
```

> **Direct answer:** the expected payment link comes from the **HTTP 200 JSON body of the post-save `GET /services/rest/record/v1/invoice/{internalId}` response**, specifically `custbody_cs_pymt_inv_paymentlink`. It does not come from `POST /invoice`, from the `Location` header, from the standard NetSuite `paymentlink` field, or from the later Globus invoice-sync response.

The CyberSource SuiteApp writes its status, error, and link onto the saved transaction during or after the save path. n8n must retrieve these exact body fields:

| Canonical field | Exact NetSuite source | Contract |
| --- | --- | --- |
| `paymentLink` | `custbody_cs_pymt_inv_paymentlink` | Full hosted URL. The standard NetSuite `paymentlink` field is prohibited as a fallback. |
| `cybersourceInvoiceStatus` | `custbody_cs_pymt_inv_invoicestatus` | Raw SuiteApp/CyberSource state. `CREATED` is the sandbox-verified payable state for delivery mode `None`. |
| `cybersourceErrorMessage` | `custbody_cs_pymt_inv_errormessage` | Processing failure. Must be blank for email eligibility and redacted before observability use. |

If the fields are not terminal on the first read, n8n repeats the same saved-invoice `GET` with bounded, environment-configurable backoff. If the deadline expires with no explicit error and no link, invoice sync still occurs with state `PAYMENT_LINK_PENDING`; no email is sent. A later `netsuite.invoice.updated` event or controlled retry re-reads the same invoice and retains its original `externalRef`. The workflow must never synthesize a URL from an invoice number or token.

A link is eligible only when the invoice ID/number match the saved transaction, the CyberSource state is payable, the error field is blank, the URL is absolute HTTPS, and its normalized hostname appears in the environment-specific CyberSource allowlist. The sandbox validated both `ebc2test.cybersource.com` and `businesscentertest.cybersource.com` route families. Production hosts must be verified from the production merchant before release; they must not be guessed from sandbox values.

### n8n creation lane, update lane, and invoice-sync request

Two read endpoints feed n8n when it builds an invoice. `GET /api/integrations/n8n/netsuite/applicants/:applicantId/application-context` (integration scope `integrations.netsuite`) resolves an applicant to one application and returns `globusApplicantId`, `globusApplicationId`, `applicationCount` and `selectionPolicy` `primary_active`; ranking is lowest first: an enrollment still in the application journey, then extension, renewal, in-program enrollment, other in-program applications, and completed or terminal-stage applications last, newest update first within a rank; 404 when the applicant has no application. `GET /api/integrations/finance/applications/:applicationId/invoice-context` (`fees.view`, application scope enforced) returns `applicationId`, `netsuiteCustomerId`, `billingEmail` and the pending charges (`externalRef`, `chargeKey`, `netsuiteItemId`, `amount`, `currency`, `quantity`, `dueDate`) built from `INVOICE_PENDING` charges raised by a billing rule. It returns 503 `INVOICE_CUSTOMER_NOT_READY` (retryable) while the finance account party has no NetSuite customer id, and 422 `NETSUITE_ITEM_ID_REQUIRED` (permanent) when a charge catalog entry has no NetSuite item id.

The implementation has two complementary lanes; they must not be collapsed into one workflow.

| Lane | Existing entry point | Required responsibility |
| --- | --- | --- |
| Initial creation | `application.created` → Dispatcher → Application Domain handler | A new dedicated delivery workflow resolves the invoice-capable customer, posts the invoice, parses `Location`, polls the saved invoice with `GET`, invokes invoice-sync, claims the email, and calls the shared SendGrid delivery. |
| Later NetSuite update | `netsuite.invoice.created` or `netsuite.invoice.updated` → Dispatcher → NetSuite Invoice Domain handler | Preserve the original invoice-event payload, surface canonical CyberSource fields when present, call the existing invoice-to-Globus delivery, and re-evaluate the claim only when a pending link becomes valid. |

The existing `[Globus] Handler: NetSuite Invoice Domain` does **not** create an invoice or read NetSuite; it only maps an incoming NetSuite invoice event. `[Globus] Delivery: NetSuite Invoice → Globus` similarly only posts mapped event data to `POST /api/integrations/n8n/netsuite/invoice-sync`. Therefore neither existing workflow is the source of the initial payment link. The canonical delivery export is `efTY6PWJgeikX7Ut__globus-delivery-netsuite-invoice-globus.json`; the duplicate Unicode-arrow export with the same workflow ID must be removed or regenerated from the canonical definition rather than edited independently.

Both lanes must map the same transport-optional fields so historical and pending invoices remain valid:

```json
{
  "paymentLink": "https://APPROVED_CYBERSOURCE_HOST/ebc2/invoicing/payInvoice/REDACTED",
  "cybersourceInvoiceStatus": "CREATED",
  "cybersourceErrorMessage": null
}
```

n8n and the Globus API must both validate URL syntax and the environment host allowlist. The Globus invoice-sync response must return the persisted invoice row, including the additive fields and its `invoiceRefId`. n8n uses that persisted row—not its pre-request copy—for notification eligibility. The route must never invent or rewrite the payment link; it only validates, stores, and echoes the value obtained from the saved-invoice GET or a later NetSuite invoice event.

### Globus persistence and update semantics

> **As built (Task #836 — shipped).** This persistence work landed, but the column names differ from the names originally specified here. `finance_invoice_refs` (`shared/models/documents-finance.ts`) carries the nullable columns **`hosted_payment_url`**, **`payment_link_fingerprint`**, **`cybersource_status`**, **`cybersource_error`**, and **`payment_link_last_synced_at`**. There is no `payment_link`, `cybersource_invoice_status`, or `cybersource_error_message` column — use the names above when writing queries or migrations.
>
> The **wire contract accepts both spellings.** `server/routes/netsuiteFinanceSync.ts` takes `hostedPaymentUrl` / `cyberSourceStatus` / `cyberSourceError` and also honours the legacy aliases `paymentLink` / `cybersourceInvoiceStatus` / `cybersourceErrorMessage`. Supplying both `hostedPaymentUrl` and `paymentLink` with different values is a `409` conflict. The URL host is validated against the approved CyberSource allowlist (`server/lib/paymentLinkHostAllowlist.ts`) before persistence; an unapproved host is rejected and never rendered as a payment link. Omitted fields on a re-sync never clear existing values.

All current invoice-sync guarantees remain mandatory: idempotent upsert on `externalRef`, integer-cent conversion and consistency validation, application-link conflict protection, concurrent unique-key create convergence, and finance-summary recomputation. A replay that omits an optional CyberSource field means **not supplied**; it must not erase a previously stored valid link or status. Clearing requires a separate explicit remediation contract.

### Durable email claim

> **Built (BackOfficePrototype #1692).** `finance_notification_deliveries` now exists in the BackOfficePrototype schema, with a claim service and two n8n-facing machine-to-machine endpoints. The table below is the original target-state contract; where the as-built implementation differs, the differences are listed directly under it. On the n8n side, the development `[Globus] Handler: Finance Invoice Request` workflow calls both endpoints around its payment-link send (Claim Payment Link Delivery → send only when `claimed` is true → Complete Payment Link Delivery). The shared SendGrid acceptance correction below is a separate requirement.

A successful source-event callback is not an email idempotency primitive. Before n8n calls SendGrid, the backend must atomically claim a semantic delivery key under a unique constraint. Add a `finance_notification_deliveries` persistence record, or an equivalent finance-specific notification audit table, with the following minimum contract:

| Field | Contract |
| --- | --- |
| `deliveryKey` | Unique semantic key; the database unique constraint is the concurrency boundary. |
| `invoiceRefId` | Foreign key to `finance_invoice_refs`. |
| `recipientNormalized` | Lowercased and normalized delivery address. |
| `paymentLinkFingerprint` | SHA-256 fingerprint; the full hosted token is never copied into the delivery key or log fields. |
| `templateVersion` | Versioned email contract so an intentional template change can be reasoned about explicitly. |
| `state` | `CLAIMED`, `ACCEPTED`, `FAILED`, or `SUPPRESSED`. |
| `leaseExpiresAt` | Time-bounded ownership for crash recovery. |
| `attemptCount` | Monotonic attempt count. |
| `providerMessageId` | SendGrid `X-Message-Id` when returned. |
| `acceptedAt`, `lastAttemptAt` | Durable lifecycle timestamps. |
| `lastErrorCode`, `lastErrorRedacted` | Operational failure data with all link tokens and secrets removed. |

The recommended key is:

```text
payment-link-email:v1:EXTERNAL_REF:PAYMENT_LINK_FINGERPRINT:RECIPIENT_NORMALIZED:TEMPLATE_VERSION
```

**As built (`server/services/financeNotificationDeliveryService.ts`, `server/routes/financeNotificationDelivery.ts`, `shared/models/documents-finance.ts`):**

- **Delivery key.** Computed server-side and never trusted from input: `sha256:` + SHA-256 of `invoiceRefId|recipientNormalized|templateVersion|paymentLinkFingerprint` (empty string when no fingerprint). It does not include `externalRef`, and the `payment-link-email:v1:...` string above is not the stored format. `delivery_key` carries a unique constraint, which is the concurrency boundary.
- **Columns.** `id`, `delivery_key`, `invoice_ref_id` (FK to `finance_invoice_refs`), `recipient_normalized`, `payment_link_fingerprint` (nullable), `template_version`, `state`, `lease_expires_at`, `attempt_count`, `provider_message_id`, `last_error` (one free-text column), `created_at`, `updated_at`. There are no separate `acceptedAt`, `lastAttemptAt`, `lastErrorCode` or `lastErrorRedacted` columns.
- **States.** `CLAIMED`, `SENT` (terminal; recorded with the SendGrid provider message id) and `FAILED` (re-claimable). There is no `ACCEPTED` or `SUPPRESSED` state. A `CLAIMED` row whose lease has expired (crashed worker) is also re-claimable.
- **Claim semantics.** The first claim inserts a `CLAIMED` row (`attempt_count = 1`). A concurrent or replayed claim hits the unique violation and converges on the existing row: `SENT` returns `claimed: false, reason: ALREADY_SENT`; a `CLAIMED` row with a live lease returns `claimed: false, reason: IN_FLIGHT`; `FAILED` or an expired lease is taken over with an atomic conditional update that increments `attempt_count`. A duplicate claim is answered with HTTP 200, so the n8n workflow must branch on `claimed` and abort its send path when it is false.
- **Endpoints** (both gated by the integration-auth check: the shared n8n system webhook key, or an API key with scope `finance.notification_deliveries.execute`):
- `POST /api/integrations/n8n/finance/notification-deliveries/claim` — called before SendGrid. Body: `invoiceRefId` (UUID), `recipient`, `templateVersion`, optional `paymentLinkFingerprint`, optional `leaseSeconds` (30–3600, default 600). Unknown `invoiceRefId` returns 404. Audited as `finance.notification_delivery.claimed`.
- `POST /api/integrations/n8n/finance/notification-deliveries/:deliveryId/complete` — called after SendGrid. Body: `outcome` (`SENT` or `FAILED`), optional `providerMessageId`, optional `error`. A `FAILED` outcome is persisted and then answered with a non-2xx (`NOTIFICATION_DELIVERY_FAILED`) so the workflow's error branch fires; reporting `FAILED` against a row already `SENT` returns 409 and cannot overwrite terminal success. Audited as `finance.notification_delivery.completed`.

| Notification state | Meaning | Required behavior |
| --- | --- | --- |
| `CLAIMED` | One worker owns a time-limited attempt. | Concurrent workers skip while the lease is live. |
| `ACCEPTED` | SendGrid returned HTTP 202. | Terminal for the delivery key; do not send again. |
| `FAILED` | Failure occurred before known acceptance. | Retry under policy with a new lease/attempt. |
| `SUPPRESSED` | Recipient or policy gate prohibited delivery. | Terminal until explicit administrative override. |

This design prevents concurrent duplicates and provides at-most-one **known accepted** send. An ambiguous network failure after SendGrid may have accepted the request but before n8n received the response cannot be represented as mathematically exact once-only delivery; it must be surfaced for reconciliation rather than treated as either success or an automatic duplicate-safe retry.

### SendGrid acceptance contract

The shared `[Globus] Delivery: SendGrid Email` workflow must be corrected before it can send payment links. Its current HTTP node continues after error and its final node returns `success: true` unconditionally. The corrected workflow validates `recipient_email`, `email_subject`, `email_html`, `delivery_key`, and `message_type=invoice.payment_link`; only a SendGrid HTTP 202 returns `accepted: true`. Timeouts, transport errors, skipped recipients, and non-2xx responses must return or throw an explicit failure.

If SendGrid provides `X-Message-Id`, the workflow should store it as the provider reference. Optional HubSpot engagement logging is secondary: HubSpot failure produces `hubspotLogged: false` but does not negate a confirmed SendGrid 202. HubSpot success cannot convert a SendGrid failure into success.

```json
{
  "success": true,
  "accepted": true,
  "statusCode": 202,
  "recipient_email": "payer@example.com",
  "delivery_key": "payment-link-email:v1:REDACTED",
  "provider_message_id": "optional-sendgrid-id",
  "hubspotLogged": true
}
```

### Completion and failure semantics

The invoice event is complete only after invoice sync succeeds and the notification outcome is durably one of: the delivery row `SENT` (a repeat claim is answered `ALREADY_SENT`), or the invoice request left in `PAYMENT_LINK_PENDING` with no email sent. `PAYMENT_LINK_PENDING` means the synchronization succeeded, a later retry/update is scheduled, and no email was sent. It is not evidence of delivery.

A 404/no-op, probe invoice, invalid host, standard-field fallback, explicit CyberSource error, missing real recipient, SendGrid transport/non-2xx failure, or unpersisted notification result must not be reported as a successful email. Error callbacks and observability records must omit the link path token.

### Required automated verification

| Layer | Minimum acceptance cases |
| --- | --- |
| NetSuite producer | Correct custom field only; standard-field fallback rejected; pending-to-ready update; explicit error; invoice-capable customer. |
| n8n | Approved HTTPS host; wrong host; HTTP/malformed URL; missing recipient; probe/no-op; bounded pending result; token-redacted error. |
| Backend/SDK | Create and replay CyberSource fields; omission does not clear; invalid URL/host rejected; existing 404/409/422/concurrency/persistence guarantees remain green. |
| Notification claim | Concurrent duplicate; live/expired lease; failed retry; accepted terminal; intentional link-fingerprint change. |
| SendGrid | HTTP 202; 4xx/5xx; timeout; transport error; missing recipient; HubSpot failure after 202. |
| Security | No credentials or payment tokens in logs, callbacks, fixtures, screenshots, workflow exports, or documentation. |

The sandbox release gate requires a fresh approved invoice, a verified invoice-capable customer, `custbody_cs_pymt_inv_invoicestatus=CREATED`, blank SuiteApp error, an approved-host link, successful Globus persistence, one durable email claim, exactly one SendGrid HTTP 202, and no token leakage. Production requires independent merchant/profile/subsidiary, accounting, credentials, feature, hostname, and log-hygiene verification.
## Phase 4 — Full Finance Ecosystem (Outline)

Full finance capabilities including multi-invoice management, partner billing, commissions, refunds, and dashboards. Payment plans, payment schedules, rate cards, and payment gates are specified on this page but are not implemented in code yet; only a deferred-fee approval flow (`fees.deferred.*` permissions) and its agreement step exist. Refund policy rules and the `REFUND_REQUESTED` event in this Phase 4 outline are not built.

Multi-Invoice Management

Multiple charge types per application (program fee, SEVIS, insurance, housing, program extension) / Per-charge status tracking and invoicing / Consolidated payment view across all charges
Partner Billing

Partner invoices for placement fees / Outstanding balance tracking on Partner Portal / Partner finance account linked to NetSuite
Refund Management

Refund policy rules in Globus (DB-driven, not hardcoded) / REFUND_REQUESTED event → NetSuite credit memo / Applicant Portal refund status display
Financial Dashboards

Program revenue summary (applicants / paid / outstanding) / Revenue by brand, program, season / Outstanding balance aging reports
## Multi-Currency Strategy

> **NetSuite Owns Exchange Rates — Globus Caches for Display**
>
> Exchange rates are managed entirely within NetSuite. Globus stores the rate that was active at invoice creation time and uses it for approximate local-currency display. Globus never performs currency conversion for billing purposes.

### Exchange Rate Source & Refresh

NetSuite maintains exchange rate tables via its built-in Multi-Currency feature or manual entry by the finance team / Rates are sourced from NetSuite's configured rate provider (e.g., Thomson Reuters, European Central Bank feed, or manual override) / Globus does not fetch rates from third-party FX APIs — all rate data arrives via the NetSuite sync pipeline / The finance_invoice_refs.exchangeRate column exists, but no server code writes it yet and the invoice-sync route has no exchange-rate handling / Local amounts are not recalculated from a synced rate in the current code
### Rate Lock Policy

The exchange rate is frozen at invoice creation time in NetSuite. This locked rate determines the local currency amount on the invoice and is stored in finance_invoice_refs.exchangeRate.

| Event | Rate Behavior |
| --- | --- |
| Invoice created in NetSuite | Rate locked — NetSuite stamps the current exchange rate on the invoice at creation time |
| Invoice synced to Globus | Locked rate stored in finance_invoice_refs.exchangeRate — does not change on subsequent syncs |
| Payment received | Payment is recorded at the rate on the invoice, not the current market rate |
| Display in portal (pre-invoice) | localAmountDue uses the latest available rate from the most recent sync — marked as approximate |
| Rate changes in NetSuite | Only affects new invoices — existing invoices retain their locked rate |

### Multi-Currency Reconciliation

Because Globus caches local currency amounts that are derived from exchange rates, drift can occur between what Globus displays and what NetSuite considers authoritative. The reconciliation rules below govern how discrepancies are detected and resolved.

USD (base currency) amounts are always authoritative — NetSuite bills in USD and Globus stores USD as the billing truth / Local currency amounts in Globus are display-only approximations derived from the locked exchange rate on the invoice / If the cached localAmount in finance_invoice_refs differs from the NetSuite-reported local amount during sync, Globus overwrites its cache with the NetSuite value / The application_finance_summary.localAmountDue is recalculated from the sum of linked invoice local amounts during each sync / Currency mismatch detection: if finance_invoice_refs.currency differs from the invoice currency reported by NetSuite, a reconciliation alert is raised for staff review / Realized FX gains/losses (difference between invoice rate and payment rate) are handled entirely within NetSuite GL — Globus does not track or display FX gain/loss
### Portal Display Rules for Currency

| Context | Currency Shown | Approximate Indicator |
| --- | --- | --- |
| Invoice detail (after invoice created) | USD (primary) + local currency from locked rate | No — rate is locked |
| Finance summary (pre-invoice) | USD (primary) + local currency estimate | Yes — shows '~' prefix when rate is older than 24 hours |
| Admin finance tab | USD + local currency + exchange rate + rate age | Staleness indicator (green/amber/red) |
| Partner portal | USD only (partners bill in USD) | No |

## Tax Strategy

> **NetSuite Tax Engine Handles All Tax Calculation**
>
> Globus does not compute tax amounts. The taxable flag in the charge catalog tells NetSuite whether a line item is subject to tax. NetSuite applies the appropriate tax code based on its jurisdiction rules, the customer's location, and the item's tax classification.

### Tax Calculation Ownership

| Responsibility | Owner | Details |
| --- | --- | --- |
| Mark charges as taxable/non-taxable | Globus (charge catalog) | The taxable boolean in finance_charge_catalog determines whether each charge type is tax-eligible |
| Determine applicable tax rate | NetSuite | NetSuite tax engine resolves the rate based on tax codes, nexus rules, and customer jurisdiction |
| Calculate tax amount on invoice | NetSuite | Tax amount is computed by NetSuite when the invoice is created — Globus never calculates tax |
| Display tax on portals | Globus (cached) | Tax amounts from NetSuite invoices are cached in finance_invoice_refs and shown on portals as line items |
| Tax compliance & filing | NetSuite / Finance team | Tax reporting, nexus tracking, and filing are handled in NetSuite — outside Globus scope entirely |

### Tax Jurisdiction for Exchange Programs

J-1 visa and Summer Work Travel (SWT) programs operate across international boundaries, creating specific tax considerations. The rules below describe how tax jurisdiction is determined for the types of charges in the finance charge catalog.

Program fees (PROGRAM_FEE, EXTENSION_FEE, RENEWAL_FEE): Not yet treated as taxable in code — every seeded catalog entry has `taxable: false`, the strict invoice request schema accepts only `taxable: false`, and the n8n invoice handler sets `isTaxable: false`. The intended model is US sales tax by the sponsor organization's nexus state, applied by NetSuite. / Government fees (SEVIS_FEE): Non-taxable — government-mandated fees are exempt from sales tax. The taxable flag is set to false in the charge catalog. / Insurance fees (INSURANCE_FEE): Tax treatment varies by state — NetSuite determines taxability based on the insurance product's tax classification and the customer's state. / Housing deposits (HOUSING_DEPOSIT): Typically non-taxable — refundable deposits are generally not subject to sales tax. Marked non-taxable in the charge catalog. / International applicants: Tax nexus is determined by the sponsor organization's US presence, not the applicant's country of origin. The applicant's location affects currency display but not tax jurisdiction.
### Tax & Payment Models

Tax applies to the payer entity, not the applicant. When the payment model shifts who pays, the tax jurisdiction and applicability shift accordingly.

| Payment Model | Tax Applied To | Jurisdiction Basis |
| --- | --- | --- |
| SELF_PAY | Applicant's invoice | Sponsor's nexus state (applicant is the customer in NetSuite) |
| DDP | Applicant's invoice (deferred schedule) | Same as SELF_PAY — sponsor's nexus state applies |
| PARTNER_PAYS | Partner organization's invoice | Partner organization's registered address in NetSuite determines tax nexus |
| SPONSOR_PAYS | No external invoice (internal) | Internal cost absorption — no sales tax applies (intercompany) |
| SPLIT | Each payer's respective invoice lines | Tax applied per-line based on each payer entity's jurisdiction in NetSuite |

> **Tax Follows the Payer, Not the Applicant**
>
> In a PARTNER_PAYS scenario, the partner organization is the NetSuite customer. Tax codes are applied based on the partner's registered address, not the applicant's home country. This is critical for correct tax calculation when partners operate in different US states or internationally.

## Architecture Questions for CTO and CFO

> **Decisions Required Before Phase 3 & 4 Design**
>
> These questions affect schema design, integration contracts, and system boundaries. They should be resolved before detailed Phase 3/4 planning begins.

## Portal Display Rules

| Data Point | Applicant Portal | Partner Portal | Globus Admin |
| --- | --- | --- | --- |
| Finance status (paid/pending) | Yes | Own invoices only | Yes |
| Payment model (DDP/Self-Pay) | Yes | No | Yes |
| Invoice list + PDF download | Phase 2+ | Phase 2+ | Phase 2+ |
| Pay Now button | Phase 3+ | No | No |
| Receipts / refund status | Phase 3+ | Phase 4+ | Phase 3+ |
| NetSuite internal IDs | Never | Never | Yes (reference) |
| GL / accounting info | Never | Never | Never (NetSuite only) |
| Partner billing details | Never | Own only | Yes |

## Finance RBAC

Finance data is sensitive. Access is governed by the existing RBAC system (see RBAC & Governance) with six finance-specific `fees.*` permissions. There are no `finance.*` staff permission keys (the `finance.invoices.*` and `finance.payment_imports.execute` names are API-key scopes that map onto `fees.*`), no bulk-import permission separate from `fees.trigger`, and no reconciliation dashboard permission.

| Permission | Who Has It | What It Controls |
| --- | --- | --- |
| fees.view | Finance Viewer, Editor and Admin presets | View finance summary, charges, invoices, payments, finance audit, charge catalog and account parties |
| fees.trigger | Finance Editor and Admin presets | Create charges, add/edit charge catalog entries, run the finance bulk import, raise invoice requests |
| fees.request | Not part of a finance preset | Request a payment from an applicant |
| fees.mark_paid | Finance Editor and Admin presets | Update the finance summary and mark charges paid |
| fees.deferred.approve | Finance Admin preset (restricted, reason required) | Approve a fee deferral |
| fees.deferred.revoke | Finance Admin preset (restricted, reason required) | Revoke a fee deferral |

> **Scope Filtering Applies**
>
> All finance permissions respect the existing RBAC scope model. A program manager for Alliance Abroad can only view/edit finance data for Alliance Abroad applications. Brand and program scope filtering is enforced at the query level, not the UI level.

> **Decision: Granular Permissions + Role Presets**
>
> The granular finance permissions remain as-is for maximum flexibility. Additionally, 3 role presets are defined as convenience bundles that map to those permissions. Organizations can assign presets for quick onboarding or assign individual permissions for fine-grained control. Presets are syntactic sugar — they expand to the underlying permissions at assignment time.

### Finance Role Presets

Each preset bundles a set of finance permissions for common staff profiles. Assigning a preset grants all included permissions; revoking the preset revokes only permissions not independently assigned.

| Role Preset | Included Permissions | Typical Assignee |
| --- | --- | --- |
| Finance Viewer | fees.view | Program coordinators, read-only finance staff |
| Finance Editor | fees.view, fees.trigger, fees.mark_paid | Finance staff who update payment data |
| Finance Admin | fees.view, fees.trigger, fees.mark_paid, fees.deferred.approve, fees.deferred.revoke | Finance managers, CFO delegates |

## Finance Audit Trail

Finance changes are high-stakes and legally significant. Every mutation to finance data is recorded in the existing audit log infrastructure (see Globus Capability Layers, Layer 4).

| Action | Audit Record Contains |
| --- | --- |
| Payment model changed | applicationId, old paymentModel, new paymentModel, actor, timestamp, source (manual / bulk_import / system) |
| Finance status changed | applicationId, old financeStatus, new financeStatus, actor, timestamp, source |
| Amount updated | applicationId, old amountDue/amountPaid, new values, actor, timestamp, source |
| Charge created | applicationId, chargeCatalogId, amount, triggerEventKey, actor (system or manual), timestamp |
| Charge status changed | chargeId, old status, new status, actor, timestamp |
| Invoice ref synced | invoiceRefId, netsuiteInvoiceId, old status, new status, source (webhook / scheduled_sync), timestamp |
| Bulk import executed | importId, row count, success count, error count, actor, timestamp, CSV filename |
| Finance status overridden | applicationId, old status, new status, override reason (required text), actor, approval chain |

> **Source Field Is Critical**
>
> Every finance audit entry includes a source field distinguishing between manual (staff edit), bulk_import (CSV upload), system (lifecycle trigger), and netsuite_sync (automated sync). This is essential for debugging discrepancies and regulatory audits.

## Reconciliation & Staleness

From Phase 2 onward, Globus caches finance status from NetSuite. Cached data can become stale. The reconciliation model ensures discrepancies are detected and resolved.

Staleness Detection

Every cached record (finance_invoice_refs, finance_payment_refs, application_finance_summary) has a lastSyncedAt timestamp / The admin Finance section currently shows only the Last Synced date as a sync indicator; the green/amber/red staleness indicator and the 'Refresh from NetSuite' button are not built / The only scheduled finance sync workflow in the repo is the CyberSource paid-invoice sync (its workflow name ends '(Development)'), which runs every 10 minutes; there is no general 15-minute sync job
Conflict Resolution

When NetSuite and Globus disagree on status, the resolution rule is simple:

NetSuite always wins for accounting data (invoice status, payment status, amounts) / Globus always wins for operational data (payment model assignment, charge triggers, payer assignment) / If NetSuite reports PAID but Globus shows INVOICED → Globus updates to PAID automatically / If Globus has a charge that NetSuite has no invoice for → flagged for staff review (not auto-corrected) / All conflict resolutions are audit-logged with source = reconciliation
Reconciliation Report

Daily automated report: applications where Globus status ≠ NetSuite status / Shows: applicationId, Globus status, NetSuite status, last sync time, suggested action / Available to finance staff only (no dedicated permission key exists; finance access follows the `fees.*` permissions, see Finance RBAC) / Unresolved discrepancies older than 48 hours escalate to finance admin alert
## Error Handling & Resilience

Finance integrations involve multiple external systems (NetSuite, payment gateways, n8n automation). Each integration point can fail independently. This section documents the failure scenarios, detection mechanisms, recovery procedures, and escalation paths specific to the finance pipeline.

> **Finance Failures Are Business-Critical**
>
> Unlike most integration failures, finance failures directly impact revenue recognition, applicant billing, and regulatory compliance. Every failure scenario must have a defined detection mechanism, a recovery path, and an escalation owner. Silent failures are unacceptable in the finance pipeline.

### NetSuite Sync Failure & Staleness Detection

The scheduled sync job between Globus and NetSuite can fail due to network issues, NetSuite API outages, authentication token expiry, or rate limiting. When the sync fails or lags, cached finance data in Globus becomes stale and portals must communicate this to users.

Failure Scenarios

| Scenario | Detection | Impact | Recovery |
| --- | --- | --- | --- |
| NetSuite API unreachable (503/timeout) | Health check endpoint returns failure; sync job logs consecutive errors | No new invoice or payment data flows into Globus; cached data becomes stale | Automatic retry with exponential backoff (30s, 1m, 5m, 15m). Alert after 3 consecutive failures. |
| OAuth token expired or revoked | 401 response from NetSuite API; health check shows auth failure | All sync operations fail until token is refreshed | Auto-refresh via OAuth refresh token. If refresh fails, escalate to finance admin for re-authentication. |
| NetSuite rate limit exceeded (429) | 429 responses logged; sync job backs off automatically | Sync slows down; data freshness degrades proportionally | Exponential backoff built into API client. If sustained, reduce sync frequency or request quota increase. |
| Partial sync failure (some records fail) | Sync job logs per-record errors; failed records flagged in sync_errors table | Specific applications show stale data while others are current | Failed records queued for retry on next sync cycle. After 3 retries, flagged for manual review. |
| Data format mismatch (schema drift) | Deserialization errors in sync job; validation failures on incoming data | New or changed fields in NetSuite break the sync pipeline | Alert engineering immediately. Sync continues for compatible records; incompatible records are quarantined. |

Staleness Thresholds & Alerting

Every cached finance record has a lastSyncedAt timestamp. Staleness is evaluated against configurable thresholds that drive both UI indicators and operational alerts.

| Threshold | UI Indicator | Alert Action |
| --- | --- | --- |
| < 5 minutes | Green dot — data is fresh | None |
| 5 – 60 minutes | Amber dot — data may be slightly stale | None (within normal operational range for scheduled sync) |
| 1 – 4 hours | Red dot — data is stale | Automated alert to finance ops channel; 'Refresh from NetSuite' button prominently displayed |
| > 4 hours | Red dot + warning banner on finance views | Escalation alert to finance admin and engineering; sync job health investigated |
| > 24 hours | Red banner: 'Finance data is significantly out of date' | P1 incident — finance sync is considered broken. Manual reconciliation required after restoration. |

Portal Display When Data Is Stale

Admin finance views show a 'Last synced: [timestamp]' indicator in the footer of the finance section with color-coded staleness (green/amber/red) / When lastSyncedAt exceeds the amber threshold (5 min), the admin indicator turns amber with tooltip: 'Data may not reflect the latest NetSuite state' / When lastSyncedAt exceeds the red threshold (1 hour), a warning banner appears above the admin finance section: 'Financial data may be out of date. Investigate sync health.' / Stale data is still displayed (not hidden) — users can see the last known state but are warned about freshness / Write operations on stale finance data (manual status changes in admin) require a confirmation: 'Finance data was last synced [X time ago]. Proceed with update?' / Applicant and partner portals never show raw staleness indicators or timestamps — they see a softer message: 'Payment information is being updated. Check back shortly.' only when data exceeds the red threshold (> 1 hour)
Recovery After Sync Restoration

When the sync job reconnects after an outage, it runs a full reconciliation pass before resuming incremental sync / The reconciliation pass compares all active application_finance_summary records against NetSuite current state / Records where Globus status diverges from NetSuite are flagged in the reconciliation report (see Finance Reconciliation Recovery below) / Automatic corrections apply when NetSuite wins (accounting data): if NetSuite says PAID and Globus says INVOICED, Globus is updated / Manual review is required when Globus has charges with no matching NetSuite invoice — these may indicate dropped events / All recovery actions are audit-logged with source = 'sync_recovery' and include the previous and new values
### Payment Gateway Failure Handling

In Phase 3 (Online Payments), a payment gateway (Stripe, Adyen, or SuitePayments) processes card and bank payments. Gateway failures can leave Globus and NetSuite in an inconsistent state if not handled carefully.

Failure Scenarios & Handling

| Scenario | Detection | Handling |
| --- | --- | --- |
| Card declined | Gateway returns decline code (insufficient_funds, card_expired, etc.) | Display decline reason to applicant. Do not update NetSuite. Applicant can retry with a different payment method. |
| Gateway timeout (no response) | HTTP timeout after configured threshold (default: 30s) | Do NOT retry automatically — the payment may have been processed. Mark the payment PENDING (the payment_status lookup has no dedicated verification value). Query gateway for transaction status before retrying. |
| Partial payment processed | Gateway confirms a partial amount (e.g., split payment where first leg succeeds, second fails) | Record the partial payment in NetSuite. Update Globus cache to PARTIALLY_PAID. Notify applicant of remaining balance. |
| Gateway webhook delivery failure | Payment succeeds at gateway but webhook to NetSuite fails or is delayed | Scheduled reconciliation job checks gateway for completed payments without matching NetSuite records. Unmatched payments are applied manually. |
| Duplicate payment submission | Idempotency key check at gateway; same externalRef submitted twice | Gateway rejects the duplicate. Globus displays the original payment status. No double-charge occurs. |
| Gateway service outage | Gateway health endpoint returns 503; all payment attempts fail | Disable 'Pay Now' buttons in portals. Show message: 'Online payments are temporarily unavailable.' Invoice remains payable via offline methods. |

Retry vs. Manual Resolution Decision Logic

| Condition | Action | Rationale |
| --- | --- | --- |
| Gateway returned a definitive decline code | Do not retry. Inform the applicant to use a different method. | Retrying a declined card wastes quota and frustrates the user. |
| Gateway timed out (no response received) | Query gateway for transaction status. Retry only if confirmed no charge. | Blind retry risks double-charging the applicant. |
| Gateway returned a transient error (500, 502, 503) | Retry up to 3 times with exponential backoff (5s, 30s, 2m). | Transient errors are typically resolved quickly. |
| Payment succeeded at gateway but NetSuite recording failed | Queue for automatic NetSuite retry. Alert finance ops after 3 failures. | Money was collected — it must be recorded in the accounting system. |
| Mismatch between gateway amount and invoice amount | Do not apply payment. Flag for manual review. | Amount mismatches could indicate fraud or data corruption. |

Globus ↔ NetSuite Consistency During Payment Failures

Globus never marks a payment as PAID until NetSuite confirms it — the gateway confirmation alone is insufficient / If a payment succeeds at the gateway but the NetSuite webhook fails, Globus shows PAYMENT_PENDING_VERIFICATION until reconciliation confirms / The reconciliation job runs every 15 minutes and checks for payments that exist at the gateway but not in NetSuite / Unreconciled payments older than 1 hour trigger an alert to finance ops for manual application / Refunds follow the same consistency model: Globus does not show REFUNDED until NetSuite confirms the credit memo / All payment state transitions are audit-logged with source (gateway, netsuite_webhook, reconciliation, manual)
### Finance Reconciliation Recovery

When application_finance_summary diverges from NetSuite truth, reconciliation procedures restore consistency. Divergence can result from sync outages, manual overrides, dropped events, or schema migration issues.

Types of Divergence

| Divergence Type | Example | Auto-Correct? | Resolution |
| --- | --- | --- | --- |
| Status mismatch (NetSuite ahead) | NetSuite: PAID, Globus: INVOICED | Yes | Globus updates to match NetSuite. Audit log: source = reconciliation. |
| Status mismatch (Globus ahead) | Globus: INVOICED, NetSuite: no invoice exists | No | Flagged for staff review. May indicate a dropped FINANCE_INVOICE_REQUESTED event. |
| Amount mismatch | Globus: $5,000 due, NetSuite: $4,500 due | No | Flagged for staff review. NetSuite amount is displayed with a discrepancy warning. |
| Missing record in Globus | NetSuite has an invoice with no matching finance_invoice_refs record | Yes | Sync job creates the missing cache record. Audit log: source = reconciliation_backfill. |
| Orphaned record in Globus | Globus has a finance_invoice_refs record for a voided NetSuite invoice | Yes | Record status updated to VOID. Audit log: source = reconciliation. |

Reconciliation Triggers

Scheduled: Daily reconciliation report runs at 02:00 UTC comparing all active application_finance_summary records against NetSuite / Post-outage: Full reconciliation pass runs automatically when sync is restored after an outage exceeding 1 hour / On-demand: Finance admin can trigger reconciliation for a single application or a batch via the admin Finance Tab / Event-driven: When a netsuite.invoice.updated event contains data that conflicts with the Globus cache, reconciliation is triggered for that record / Threshold-based: If the daily reconciliation report finds > 50 discrepancies, a P2 incident is raised for systemic investigation
Audit Trail for Reconciliation Actions

Every reconciliation action writes an entry to the audit log with: applicationId, field changed, old value, new value, source (reconciliation / reconciliation_backfill / manual_reconciliation), actor (system / staff user ID), timestamp / Auto-corrections are logged with source = 'reconciliation' and actor = 'system' / Manual reconciliation actions are logged with source = 'manual_reconciliation' and the staff user who approved the change / Reconciliation reports are archived for 90 days and accessible to finance staff (there is no `finance.reconciliation.view` key and no reconciliation permission at all; see Finance RBAC) / Discrepancies that are manually dismissed (staff confirms Globus is correct) are logged with resolution = 'dismissed' and a required justification note
### Finance Escalation Paths

Finance failures require clear escalation paths with defined owners, timeframes, and communication channels. Each failure type has a designated first responder and an escalation chain.

Escalation Matrix

| Failure | First Responder | Escalation (if unresolved) | Timeframe |
| --- | --- | --- | --- |
| NetSuite sync failure (< 1 hour) | Automated monitoring (self-healing retry) | Finance Ops team via Slack alert | Auto-resolve within 1 hour |
| NetSuite sync failure (> 1 hour) | Finance Ops team | Engineering on-call + Finance Admin | Acknowledge within 30 min, resolve within 4 hours |
| NetSuite sync failure (> 4 hours) | Engineering on-call | CTO + Finance Director | P1 incident — continuous attention until resolved |
| Payment gateway outage | Engineering on-call | CTO + gateway vendor support | Disable online payments within 15 min, escalate to vendor |
| Invoice stuck in INVOICED > 30 days | Finance Ops (daily reconciliation report) | Finance Admin for review | Flagged on day 30, escalated on day 45 |
| Payment mismatch detected | Finance Ops (reconciliation alert) | Finance Admin + Accounting team | Review within 24 hours, resolve within 48 hours |
| Dropped finance event (charge exists, no invoice) | Finance Ops (reconciliation report) | Engineering to investigate event outbox | Review within 24 hours |
| Reconciliation report > 50 discrepancies | Finance Admin | Engineering + CTO (systemic issue) | P2 incident — investigate root cause within 4 hours |

Notification Channels

Automated alerts: Slack channel #finance-alerts for all sync failures, staleness threshold breaches, and reconciliation discrepancies / Email notifications: Finance Admin receives a daily digest of unresolved discrepancies and stuck invoices / P1/P2 incidents: Triggered via the standard incident management process (PagerDuty / on-call rotation) for critical finance failures / Dashboard: Finance health dashboard shows real-time sync status, staleness heatmap, and reconciliation summary (no dedicated permission key exists; see Finance RBAC) / Applicant-facing: Applicants are never notified of backend finance failures — they see soft staleness messages in the portal instead
> **Applicants Never See Backend Failures**
>
> Finance error handling is entirely backend and ops-facing. Applicants and partners see soft, user-friendly messages about data freshness. They are never exposed to sync failure details, reconciliation alerts, or gateway error codes. The portal degrades gracefully with cached data and clear “check back shortly” messaging.

## Payment Step Type (Workflow Studio)

Scoped under FN-1 · Assumes P11 will be built 
A Payment step type is a blocking workflow step that gates applicant progression until payment clears. Unlike other step types (forms, uploads, instructions, document review), a payment step integrates directly with the charge trigger system and requires finance infrastructure to be operational before it can function.

Concept

Configured as a step in the Workflow Studio (P11) step library, but with payment-specific behavior / When an applicant reaches a payment step, progression is blocked until the associated charge is marked PAID / The step renders payment status from the finance system — not a form or upload / Charge trigger fires when the applicant enters the payment step, creating the charge in application_charges / Payment confirmation flows back from NetSuite via the existing sync pipeline, unblocking the step
Relationship to Charge Triggers

Payment steps use the same charge trigger system defined in the Charge Trigger Events section above / Charge trigger event keys are the dotted lifecycle keys in the charge catalog (for example application.accepted, extension.approved, renewal.approved); no step-entry trigger event (STEP_ENTERED) exists in code yet / The charge catalog entry determines the fee type, amount, and NetSuite item mapping / Payment model (SELF_PAY, DDP, PARTNER_PAYS) determines who sees the payment step and who is billed
Admin-Only Configuration

Payment steps can only be added to workflows by users with finance admin permissions / Misconfigured payment steps could block applicants indefinitely or trigger incorrect charges / Charge catalog changes require the fees.trigger permission; no workflow.step.manage permission exists in code / Step configuration includes: linked charge catalog entry, blocking behavior (hard block vs. soft warning), and display text
Compliance Requirements

Payment step creation and modification are audit-logged with actor, timestamp, and change details / Charge amounts displayed to applicants must match the finance_charge_catalog entry exactly — no client-side overrides / Payment step cannot be removed from a published workflow if any active applicant has reached it / All payment status displayed in the step is sourced from cached NetSuite data — never computed locally
Dependencies

Scoped under FN-1 (NetSuite Core Integration) — payment steps use the core charge trigger, invoice sync, and payment status infrastructure / Assumes P11 (Workflow Studio) will be built — payment is a new step type alongside form, upload, instruction, and document review. Payment step type is a Phase 1 deliverable that gates applicant workflow progression. / FN-5 (Online Payments) may later add inline \"Pay Now\" within the payment step as an enhancement — without FN-5, payment steps display status but cannot collect payment inline
> **Payment Steps Are Not Regular Steps**
>
> A payment step is not just a form with a “pay” button. It is a system-level integration point between the Workflow Studio and the finance pipeline. The step type must enforce blocking behavior, respect payment model visibility rules, and source all financial data from the NetSuite sync cache. This is why it requires admin-only configuration and depends on both P11 and FN-1 being operational.

## Design Principles

t duplicate / Globus stores NetSuite references and cached status for display performance. NetSuite remains the accounting truth. Cached data includes lastSyncedAt for staleness detection.,
Idempotent event contracts / Every finance event uses externalRef for safe retry. NetSuite invoice creation can be called multiple times without duplicate invoices.,
Payment model drives visibility / The paymentModel value on application_finance_summary determines which portal shows financial data. PARTNER_PAYS hides applicant-facing invoices.,
Finance event outbox / All Globus → NetSuite communication goes through the event outbox (Layer 9) so retries are safe, auditable, and resilient to NetSuite downtime.,
Separation of concerns / Globus decides what to charge and when. NetSuite handles how it
> **Integration with Existing Architecture**
>
> Finance events use the same event outbox and n8n delivery model as all other Globus integrations (see n8n Automation Layer). Portal display respects existing RBAC scope rules (see RBAC & Governance). Financial data flows follow the same system-of-record principles as all other data (see Data Flow Between Systems).

## References

[1]: https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1545141395.html "Oracle NetSuite — Creating a Record Instance"
[2]: https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1545141500.html "Oracle NetSuite — Getting a Record Instance"
[3]: https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_161488248489.html "Oracle NetSuite — Invoice"

## Verified Development CyberSource Pay-by-Link Runbook

> **Current implementation status (2026-08-17):** The development invoice-to-hosted-link path is verified. The automated completed-payment return path from CyberSource through NetSuite and back to Globus is designed and source-controlled, but it is **not yet deployed or end-to-end verified**. This section must be read as an operational guide for **CyberSource Sandbox, NetSuite Sandbox, development n8n, and Globus Development only**. Do not use it for production, real cards, or direct card processing in a local browser.

### System-of-record rule for this flow

Globus decides what should be charged and emits the finance request. NetSuite remains the accounting system of record for the invoice and payment. CyberSource Sandbox hosts the payment page. Globus can show a cached payment status only after NetSuite reports the result; it must not independently declare a payment successful.

### 1. Raise the invoice and generate the sandbox payment link

Use the Globus Development finance.invoice_requested flow. Do not call a CyberSource Suitelet directly and do not treat a payment link as a NetSuite customer payment.

| Step | Responsible system | Verified outcome |
|---|---|---|
| 1 | Globus Development | Sends finance.invoice_requested with invoice, payer, amount, and correlation context to the development finance handler. |
| 2 | Development n8n | Ensures the NetSuite customer and creates or updates the NetSuite Sandbox invoice. |
| 3 | Development n8n | Requests CyberSource Pay-by-Link on the invoice, selects the configured sandbox merchant context, and disables NetSuite email, CyberSource email, printing, fax, and fulfillment. |
| 4 | NetSuite Sandbox CyberSource SuiteApp | Generates the hosted CyberSource Sandbox URL, writes it to custbody_cs_pymt_inv_paymentlink, and sets the invoice-side CyberSource state to CREATED when successful. |
| 5 | Development n8n | Polls the invoice every 10 seconds for up to six attempts, validates the URL is HTTPS and on the permitted sandbox CyberSource host, then sends it to Globus through the development invoice-sync callback. |

A valid link result requires all of the following: the invoice exists with the expected correlation context; custbody_cs_pymt_inv_paymentlink is a non-empty HTTPS sandbox URL; the CyberSource invoice state is CREATED; no invoice-email, payment-email, print, fax, or fulfillment action is enabled; and Globus receives the same validated link.

The Suitelet pair CUSTOMSCRIPT_CS_PYMT_INV_CUSTOM_CREATE / CUSTOMDEPLOY_CS_PYMT_INV_CUSTOM_CREATE belongs to the existing **payment-link creation** process. It is not the post-payment import API and must never be used as NETSUITE_PAYMENT_IMPORT_RESTLET_URL.

### 2. Receive the completed sandbox payment in NetSuite

The existing CyberSource SuiteApp owns the current post-payment worker:

| Property | Verified value |
|---|---|
| Script ID | customscript_cs_pymt_invpaymentupdate |
| On-demand deployment | customdeploy_cs_pymt_inv_pymt_on_demand |
| Script type | SuiteScript 2.1 Map/Reduce |
| Declared purpose | Fetch paid invoices from EBC/CyberSource and update the payment ID on the corresponding NetSuite invoices. |
| Deployment scheduling | Not scheduled. |
| Configured per-invoice input parameter | None. |

This worker is **not** a RESTlet, so the normal NetSuite REST Record API cannot invoke it directly. It can be submitted from inside NetSuite using N/task or through the Sandbox UI. Because the deployment has no configured per-invoice parameter, an uncontrolled manual run may process every eligible sandbox payment. Do not enable, schedule, or submit it blindly for a single invoice.

### 3. Return the verified NetSuite result to Globus

The development finance handler already has an inactive-by-configuration finance.payment_import_requested route. It expects the environment variable NETSUITE_PAYMENT_IMPORT_RESTLET_URL and a RESTlet supporting the following contract:

| Action | Required inputs | Intended result |
|---|---|---|
| submit | idempotencyKey, correlationId, reason; optional expectedInvoiceId | Reuse or create an idempotency-ledger record, submit the native Map/Reduce worker once, and return taskId. |
| status | taskId | Return the task state, current stage, terminal flag, and correlation ID. When the submit carried an expectedInvoiceId, a COMPLETE task is reported successful only after the RESTlet re-reads that invoice and confirms amountremaining is zero or less (returning expectedInvoicePaid: true and the invoice detail); otherwise the request is marked FAILED and the RESTlet returns EXPECTED_INVOICE_NOT_PAID. |
| paidcandidates | optional limit (integer 1-50, default 25) | Search NetSuite invoices that are Paid In Full, carry CyberSource invoice status PAID and a non-empty payment link, and return up to limit candidates (positive amount, numeric invoice ID only) as sourceInvoiceId, invoiceNumber, amount, currency, sourcePaymentId (netsuite:invoice:{id\}:paid), paymentGateway CyberSource and paymentStatus PAID. |

The required custom payment-import RESTlet has been designed and contract-tested, but it is not yet deployed to NetSuite Sandbox. Its design uses a durable ledger to prevent duplicate updater submission: it stores a PREPARED request before submission, replays the existing task for a duplicate idempotency key, and leaves an uncertain submission in UNKNOWN for manual review rather than silently retrying.

> **Current known gap:** NETSUITE_PAYMENT_IMPORT_RESTLET_URL is absent from the development n8n workload, and no compatible custom RESTlet exists in the sandbox. A full sandbox RESTlet inventory found no existing endpoint that implements the handler's required submit / status contract. The legacy CyberSource link-creation Suitelet is not compatible.

### Scheduled paid-invoice sync and payment attribution

The repository carries an n8n workflow, [Globus] Scheduled CyberSource Paid Invoice Sync (Development), triggered every 10 minutes. It calls the payment-import RESTlet paidcandidates action (limit 25) and validates each candidate (numeric sourceInvoiceId, sourcePaymentId present, positive amount, paymentStatus PAID, paymentGateway CyberSource; duplicate sourcePaymentIds are dropped). It then POSTs each one to /api/integrations/n8n/netsuite/payment-sync with sourcePaymentId, sourceInvoiceId, externalRef set to the invoice number, amount, paymentGateway, paymentStatus and currency. A candidate Globus cannot attribute to an application is skipped as an unattributed legacy invoice (skipReason UNATTRIBUTED_LEGACY_INVOICE) rather than failing the run; any other Globus error fails it. The committed workflow JSON has `active` set to `false`, so the schedule does not run until the workflow is enabled in the target n8n instance.

POST /api/integrations/n8n/netsuite/payment-sync attributes a payment to an application in this order: an explicit applicationId (404 when the application is unknown), then the Globus invoice reference whose NetSuite sourceInvoiceId matches, then the invoice reference whose externalRef matches, and finally the application of an existing payment with the same sourcePaymentId. A payment that still cannot be attributed is rejected with a VALIDATION_ERROR (HTTP 400, message "Cannot attribute payment to an application"). Payments are upserted on sourcePaymentId, a re-sync cannot move a payment to a different application (409), and the application finance summary is recomputed afterwards.

### 4. Definition of a completed reconciliation

A payment is reconciled only when all three independent conditions are true:

1. The appropriate CyberSource Sandbox payment has completed.
2. The native CyberSource worker has recorded the payment result on the corresponding NetSuite Sandbox invoice.
3. Globus Development has received the verified NetSuite result and shows the invoice as PAID.

Do not mark Globus paid based solely on a hosted payment page result, a n8n delivery attempt, or a submitted NetSuite task. NetSuite accounting state remains the final authority.

### Safe troubleshooting

| Symptom | Safe response |
|---|---|
| No payment link appears within the polling window | Inspect the sandbox invoice's CyberSource error field and the development n8n execution. Do not retry via a production endpoint or substitute the payment-import RESTlet URL. |
| The link is not an HTTPS CyberSource Sandbox URL | Stop and verify the sandbox merchant configuration and n8n URL allowlist before exposing the link. |
| Payment completed in CyberSource but no NetSuite payment ID exists | Review the native updater's source or execution selection criteria before one controlled Sandbox run. |
| NetSuite has updated but Globus is not paid | Inspect the payment-import RESTlet task status and the development callback path; do not directly override the accounting state in Globus. |
| A delivery is retried | Keep the same idempotency key. The payment-import RESTlet must replay the existing task rather than submit a second updater task. |

### Verification status

The existing invoice reconciliation for **invoice 109916** and a fresh independent sandbox-payment reconciliation are both still pending. Neither must be represented as paid until the three-condition reconciliation rule above is proven.
