---
title: "Applicant Portal — Payments & Application Fees"
slug: applicant-payments
section: Applicant Journey
status: draft
description: "The portal-side surface that sits on top of the finance model."
last-updated: 2026-10-07
---

# Applicant Portal — Payments & Application Fees

The portal-side surface that sits on top of the finance model. The applicant portal reflects payment state and collects payment input — it never holds money, computes fees, or owns accounting truth.

> **Three-System Anchor**
>
> NetSuite is the system of record for invoices, payments, and refunds. Globus caches payment status and decides what should be charged, who pays, and when. The applicant portal renders whatever Globus returns and forwards payment intent back. If a value is not on the cached fee object, the portal does not invent it.

## As-Built Globus Finance Backend (2026-07-04)

> **Backend LIVE, portal surface unbuilt — and several table claims below are wrong**
>
> The Globus finance backend is shipping; the applicant-facing fee card is not (no /api/applicant/payments* route exists and PPPToo has no payments controller; AP-22 is PENDING SCOPING). Corrections to the field sourcing below: finance_rate_cards, finance_payment_plans and finance_payment_plan_schedules do not exist; the catalog column is display_name (not name); charges carry charge_status (not status) and there is no per-charge paidToDate — paid/outstanding amounts live at summary level. The page's initiated/processing/paid/failed/refunded lifecycle is a portal-facing abstraction; map it to the stored values before freezing the contract.

| Piece | As built (shared/models/documents-finance.ts + server/routes/finance.ts) |
| --- | --- |
| Tables | finance_account_parties, finance_charge_catalog, finance_invoice_refs, application_finance_summary, application_charges, finance_payment_refs |
| application_charges | chargeCatalogId, payerPartyId, amount (integer minor units), currency, quantity, chargeStatus (default PLANNED), triggerEventKey, dueDate, netsuiteInvoiceRef, metadata |
| finance_charge_catalog | chargeKey (unique), displayName, defaultAmount, defaultCurrency, taxable, netsuiteItemId, triggerEventKey |
| finance_payment_refs | paymentGateway (default MANUAL; Stripe/PayPal named as external gateways), gatewayRef, paymentStatus (default PENDING), paidAt, rawPayload |
| Status enums | Summary financeStatus: NOT_BILLABLE_YET \| INVOICED \| PARTIALLY_PAID \| PAID (server/services/financeSummary.ts); non-settling payment statuses FAILED/VOIDED/VOID/CANCELLED/REFUNDED; summary carries paymentModel (default SELF_PAY), amountDue/Paid/Outstanding, lockReason |
| Staff routes | GET/PUT /api/applications/:id/finance-summary; GET/POST /api/applications/:id/charges; POST /api/applications/:id/payment-requests; PATCH /api/charges/:id; GET /api/applications/:id/invoices; GET/POST /api/applications/:id/payments; GET /api/applications/:id/finance-audit; GET/POST /api/finance/charge-catalog (+PATCH /:id); GET/POST /api/finance/account-parties; POST /api/finance/bulk-import; GET /api/finance/overview |
| NetSuite sync | server/routes/netsuiteFinanceSync.ts (n8n: invoice-sync + payment-sync); catalog seed server/seed/financeChargeCatalog.ts |

## Verified CyberSource Pay-by-Link Handoff

The July 15, 2026 sandbox validation fixed the provider and delivery contract for **invoice payment links**, even though the applicant-facing portal surface remains unbuilt. The existing `application.created` event is the invoice trigger: the canonical dispatcher routes it to the Application Domain handler, whose new dedicated delivery branch creates the invoice. The separately emitted `applicant.created` event and its NetSuite contact-upsert workflow are not an invoice-readiness signal and must not be joined to this flow.

The NetSuite create and read responses have different responsibilities. A successful `POST /services/rest/record/v1/invoice` returns HTTP `204 No Content`; its `Location` header identifies the saved invoice.[1] n8n then calls `GET /services/rest/record/v1/invoice/{internalId}?fields=...`; the expected hosted URL comes from the HTTP `200` JSON body field `custbody_cs_pymt_inv_paymentlink`, together with the CyberSource status and error fields.[2] The create response itself does not contain the payment link. n8n synchronizes the approved URL into Globus as `paymentLink` and emails the payer only after Globus has persisted the same link. NetSuite's standard `paymentlink` field is not a valid fallback.

| Boundary | Required behavior |
| --- | --- |
| Globus trigger | Reuse `application.created` in the canonical dispatcher envelope; derive a deterministic invoice `externalRef`; do not depend on `applicant.created` delivery order. |
| NetSuite create response | Return HTTP 204 and a `Location` header identifying the saved invoice; no JSON payment-link body is expected. |
| NetSuite saved-record response | Return HTTP 200 JSON containing `custbody_cs_pymt_inv_paymentlink`, `custbody_cs_pymt_inv_invoicestatus`, and `custbody_cs_pymt_inv_errormessage`; tolerate a pending link after initial save. |
| n8n | Parse the invoice ID from `Location`, poll the saved-record GET with bounded backoff, and require an approved CyberSource HTTPS host, payable state, blank SuiteApp error, positive amount due, and validated recipient. |
| Globus | Persist the link as `hosted_payment_url` (wire input `paymentLink`), its `payment_link_fingerprint`, `cybersource_status`, and `cybersource_error` additively on `finance_invoice_refs`; omitted fields must not clear a previously valid link. |
| Email delivery | Claim a durable semantic delivery key, invoke the shared SendGrid workflow, and mark delivery accepted only when SendGrid returns HTTP 202. |
| Portal | May later render a Pay Now link from the Globus-cached invoice object; it must never synthesize, rewrite, or infer a provider URL. |

CyberSource `deliveryMode=None` is deliberate: CyberSource publishes the hosted form but does not email the applicant. n8n and SendGrid own that communication. Link creation and form rendering are proven; card authorization, settlement, refund, and reconciliation remain outside this specification and must not be inferred from the onboarding result.

See [Finance & NetSuite Integration](./finance-netsuite#phase-3-cybersource-pay-by-link-delivery) for the normative workflow, persistence, idempotency, callback, test, and deployment requirements, and [CyberSource Pay-by-Link Sandbox Onboarding Record](./cybersource-pay-by-link-onboarding) for the complete evidence account.

## Scoping Status

> **Asana scoping ticket filed for the Applicant Portal team**
>
> A scoping ticket has been filed in the FN-3 / AP-22 project on Asana in the canonical house format: Applicant Portal: AP-22 payments & application fees — PRD scoping pass . The ticket carries the three-system anchor, the fee-card surface, the lifecycle states, the Payment Gate interaction, the draft data contract, and the Gherkin acceptance criteria the new PRD section must satisfy. The consolidated PRD update brief for the AP team lives at docs/applicant-portal-prd-update-brief.md.

## Purpose & Scope

This spec defines the applicant-facing payment surface for AP-22: where it appears in the journey, what the applicant sees, what actions they can take, the lifecycle states surfaced to them, and how the portal interacts with Payment Gates that block compliance milestones until payment clears. It does not define the checkout UI or back-office finance workflow. CyberSource is the verified hosted-form provider for invoice Pay-by-Link; n8n and SendGrid are the verified email-delivery path.

## System of Record Anchor

See Finance & NetSuite Integration for the full model. The relevant boundaries for the portal:

NetSuite

System of record. Holds invoices, payments, refunds, receipts, and accounting journals. Source of truth for status.

Globus

Caches payment status from NetSuite. Decides what to charge (rules + rate cards) and which compliance milestones a charge gates. Emits notifications on state change.

Applicant Portal

Reflects cached status, surfaces fee cards, collects payment intent, and forwards to Globus. Holds no money, computes no totals, owns no accounting state.

## Where the Payment Surface Appears

The Payments tab is a persistent surface in the applicant portal, available from the moment the first charge is created. Inline fee cards also appear within phase-specific surfaces when a charge gates the next compliance milestone — see Applicant Lifecycle .

### Trigger Events

| Phase | Lifecycle Event | Charge Created | Where the Card Renders |
| --- | --- | --- | --- |
| Phase 3 — Interview | APPLICATION_ACCEPTED | Program Fee (PROGRAM_FEE) | Payments tab + inline banner on the post-approval landing. |
| Phase 5 — Visa & SEVIS | sevis.registration | SEVIS Fee (SEVIS_FEE) | Payments tab + inline card on the Visa & SEVIS surface, blocking SEVIS submission readiness. |
| Phase 4 — Placement | insurance.enrollment / housing.assigned | Insurance / Housing deposits (provider-dependent) | Payments tab + inline card on the placement confirmation surface. |
| Phase 7 — In Program (Extension) | extension.approved | Extension Fee (EXTENSION_FEE) | Payments tab + inline card in the extension confirmation flow. |

## Applicant-Facing Surface

The atomic unit is the fee card — one card per outstanding or recently-resolved charge. Cards stack on the Payments tab in due-date order and may also render inline within phase-specific surfaces when a charge gates the next step.

### Fee Card Fields

| Field | Source | Display |
| --- | --- | --- |
| field | source | display |

_(row above is a template, repeated for each data row)_

### Fee Card Actions

| Action | When Visible | Behavior |
| --- | --- | --- |
| action | visibility | behavior |

_(row above is a template, repeated for each data row)_

## Payment Lifecycle States

Five states surfaced to the applicant. The portal does not transition state itself — every change is observed via a refreshed fee object from Globus, which in turn caches NetSuite truth.

initiated →
processing →
paid 
failed 
refunded 
| State | Meaning |
| --- | --- |
| | meaning |

_(row above is a template, repeated for each data row)_

### State Copy Slots

Each state owns a single copy slot rendered on the fee card. Variables in braces are substituted from the fee object — no other dynamic copy is allowed in v1.

| State | Card Title | Body Slot |
| --- | --- | --- |
| state | title | body |

_(row above is a template, repeated for each data row)_

### Portal Behavior on Transition

| From | To | Portal Action |
| --- | --- | --- |
| from | to | portalDoes |

_(row above is a template, repeated for each data row)_

> **Notification Ownership**
>
> The portal does not send notifications on payment transitions. Notifications are emitted by Globus per the catalog in Notification Model. The portal’s only responsibility on transition is the visible UI change.

## Payment Gate Interaction

Some compliance milestones in the applicant lifecycle are gated by payment status. The gate decision is made by Globus (per Compliance Payment Gates); the portal’s job is to communicate the blocked state clearly and unblock the moment Globus reports paid.

### Blocked-on-Payment State

- The phase action button (e.g., “Submit for SEVIS”) is disabled with a tooltip: “Payment of required before this step.”
- An inline fee card renders directly above the disabled action, with a primary “Pay Now” button.
- The phase progress indicator surfaces a “Waiting on payment” sub-state — see the lifecycle stepper.
- The Payments tab badge increments to reflect the outstanding charge.
### Unblock Flow

- Applicant pays via the surface defined by the chosen provider (Phase 3+).
- NetSuite confirms the payment and updates Globus via the cached payment ref.
- Globus flips application_charges.charge_status to paid and emits the gate-release event.
- On the next event/webhook-driven refresh the fee card transitions to the paid state and the gated action becomes enabled.
- Portal renders a one-time confirmation toast referencing the unlocked next step.
> **Portal Never Self-Unblocks**
>
> Even if the applicant has just clicked Pay and the gateway returns success, the portal does not unblock the gated action until Globus confirms the cached status is paid. This prevents the portal from getting ahead of NetSuite truth on slow webhook delivery.

## Draft Data Contract

The portal expects a single Globus endpoint to return all fee cards for the current application, and a second endpoint to accept payment intents. Field names are draft and will harden once the provider is chosen.

### Fee Object (Globus → Portal)

Endpoint: GET /api/applicant/payments — returns .

| Field | Type | Notes |
| --- | --- | --- |
| field | type | desc |

_(row above is a template, repeated for each data row)_

### Payment History Item (Globus → Portal)

| Field | Type | Notes |
| --- | --- | --- |
| field | type | desc |

_(row above is a template, repeated for each data row)_

### Payment Intent Request (Portal → Globus)

Endpoint: POST /api/applicant/payments/intent — returns a provider-specific redirect or client secret. Globus owns the call to the gateway; the portal never talks to the gateway directly.

| Field | Type | Notes |
| --- | --- | --- |
| field | type | desc |

_(row above is a template, repeated for each data row)_

## Open Questions

Tracked here so the team has a single place to look. None of these block drafting the portal contract; all of them harden the contract once decided.

## Out of Scope

## Cross-References

- Finance & NetSuite Integration — system of record, charge catalog, rate cards, payment gates.
- Applicant Lifecycle — phase placement and the gated milestones the payment surface unblocks.
- Notification Model — who fires payment-related notifications (not the portal).

## 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"
