Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ $ make wdeps-test

## Documentation

@TODO Please write a couple of words about what your project does and how it does it.
Hellgate is the core payment-processing service of the platform. It orchestrates
the full lifecycle of invoices, payments, refunds, chargebacks and recurrent
paytools on top of a set of hierarchical, event-sourced state machines, and
drives external payment providers through the `proxy-provider` Thrift protocol.

The documentation for business logic and internal mechanics lives under
[`doc/`](doc/index.md) — start at [`doc/index.md`](doc/index.md) for the full
table of contents covering architecture, state machines, routing, limits and
accounting, provider integration, risk/repair, and domain/party resolution.

[1]: http://erlang.org/doc/man/shell.html
147 changes: 147 additions & 0 deletions doc/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Architecture overview

## What Hellgate is

Hellgate (sometimes referred to as *payment processing* or the *processing
core*) is the service that owns the business-logic view of every invoice and
payment in the platform. Everything that happens to money — creating an
invoice, authorising a card, capturing a hold, paying out after settlement,
refunding, charging back, re-trying against a different provider — is a
transition on a Hellgate state machine.

Hellgate is intentionally *not* an API gateway: it is invoked by the
customer-facing API (capi/capi-pcidss and similar) over Woody/Thrift, and it
consumes a set of backend services in turn. It is the source of truth for the
*state* of each invoice and payment; balances live in the accounter (shumway)
and provider-side data lives on the providers.

## OTP applications

The release is composed of five OTP applications under
[`apps/`](../apps):

| Application | Responsibility |
| ------------------ | -------------- |
| `hellgate` | All business logic: state machines, sessions, routing hooks, limits, accounting, risk, repair, invoice templates. |
| `hg_proto` | Thrift service definitions, Woody service wrapper, protocol helpers. This is the module that mounts the Woody servers and marshals/unmarshals Thrift terms. |
| `hg_client` | Woody client for the public invoicing and invoice-templating APIs. Used from tests and ad-hoc tooling. |
| `hg_progressor` | Progressor (the newer event-sourced automaton backend) integration: wraps Progressor RPC, encodes/decodes events, propagates OpenTelemetry context, and exposes a `Processor` callback that Progressor invokes to run Hellgate machines. |
| `routing` | Routing logic as a standalone app: candidate gathering, scoring, rejection tracking, route explanations. The `hellgate` app calls into it but keeps no routing state itself. |

## External services

Hellgate is one piece of a wider microservice ecosystem. The dependencies it
consumes are shown below with the Hellgate module that wraps each one:

| Service | Purpose | Wrapper module |
| ----------------- | --------------------------------------------------------- | -------------- |
| DMT (`dmt_client`) | Versioned domain configuration (providers, terminals, proxies, payment institutions, routing rules, fees, limits, categories, currencies). Every domain lookup in Hellgate goes through `hg_domain`. | [hg_domain.erl](../apps/hellgate/src/hg_domain.erl) |
| party-management | Party/shop configuration, operability, contracts. | [hg_party.erl](../apps/hellgate/src/hg_party.erl) |
| limiter / liminator | Turnover limit enforcement (`Get`, `Hold`, `Commit`, `Rollback`). | [hg_limiter.erl](../apps/hellgate/src/hg_limiter.erl), [hg_limiter_client.erl](../apps/hellgate/src/hg_limiter_client.erl) |
| shumway (accounter)| Double-entry accounting. Hellgate submits and commits posting plans. | [hg_accounting.erl](../apps/hellgate/src/hg_accounting.erl) |
| bender | Deterministic ID generation. Hellgate uses Bender-style IDs for invoices, payments, refunds, chargebacks. | Called through `hg_client`/party-management; no dedicated wrapper module. |
| cubasty (customer)| Storage for saved/recurrent payment resources. | [hg_customer_client.erl](../apps/hellgate/src/hg_customer_client.erl) |
| fault-detector | Rolling provider availability and conversion statistics. Used to mark dead adapters as unrouteable. | [hg_fault_detector_client.erl](../apps/hellgate/src/hg_fault_detector_client.erl) |
| proxy-provider | One Woody endpoint per provider adapter; implements `ProcessPayment`, `HandlePaymentCallback`, `GenerateToken`. | [hg_proxy_provider.erl](../apps/hellgate/src/hg_proxy_provider.erl), [hg_session.erl](../apps/hellgate/src/hg_session.erl) |
| proxy-inspector | Risk scoring and card-token blacklists. | [hg_inspector.erl](../apps/hellgate/src/hg_inspector.erl) |
| machinegun | Legacy event-sourced automaton backend. | Abstracted behind `hg_machine`. |
| progressor | Current event-sourced automaton backend (default). | [hg_progressor.erl](../apps/hg_progressor/src/hg_progressor.erl) |

## Backends: Machinegun, Progressor, Hybrid

All persistent state in Hellgate lives in an event-sourced automaton. The
backend selector lives in [hg_machine.erl:230](../apps/hellgate/src/hg_machine.erl):

```erlang
call_automaton(Function, Args) ->
call_automaton(Function, Args,
application:get_env(hellgate, backend, machinegun)).
```

- `machinegun` — legacy backend using Thrift automaton RPC.
- `progressor` — newer native backend ([`config/sys.config`](../config/sys.config)
sets this in production).
- `hybrid` — route some namespaces to Machinegun and others to Progressor via
[`hg_hybrid.erl`](../apps/hg_progressor/src/hg_hybrid.erl). This is the
migration mode.

Regardless of backend, Hellgate is the *processor*: the backend tells it
"here is a machine's current history and the incoming signal/call", Hellgate
returns `{events, action, auxst}`, and the backend persists the new events.

## End-to-end flow of a payment

A simplified trace of `CreateInvoice → StartPayment → captured`:

```mermaid
sequenceDiagram
autonumber
participant C as Client (capi)
participant W as hg_woody_service_wrapper
participant I as hg_invoice
participant P as hg_invoice_payment
participant R as hg_routing
participant L as hg_limiter
participant CF as hg_cashflow / hg_accounting
participant S as hg_session
participant Pr as proxy-provider
participant A as automaton backend

C->>W: Thrift: CreateInvoice
W->>I: start invoice machine
I->>A: append ?invoice_created
C->>W: Thrift: StartPayment
W->>I: call
I->>P: delegate
P->>L: check + hold shop / payment limits
P->>R: gather_routes (+ fault detector, blacklist, pins)
R-->>P: chosen route
P->>CF: finalize cashflow + plan in shumway
P->>S: create session, ProcessPayment
S->>Pr: ProcessPayment
Pr-->>S: intent = finish | sleep | suspend
Note over S,Pr: async callbacks go to<br/>ProviderProxyHost:ProcessPaymentCallback
S-->>P: session result
P->>L: commit payment limits
P->>CF: commit posting plan
P->>A: append events
W-->>C: response
```

On provider failure the same payment can cascade to the next candidate route
(see [Routing](routing.md) and [State machines](state-machines.md#cascade-and-retries)),
so a single business-level payment may correspond to several sessions.

> [!IMPORTANT]
> Hellgate pins a domain revision at the start of the call and passes it
> through routing, term resolution and accounting. A config change landing
> mid-payment will not affect the decision — the payment stays on its
> original view of the world.

## Thrift service surface

Hellgate *exposes* these services (see [`hg_proto.erl`](../apps/hg_proto/src/hg_proto.erl)):

| Path | Interface | Purpose |
| ----------------------------------------- | ------------------------------------------------- | ------- |
| `/v1/processing/invoicing` | `dmsl_payproc_thrift:Invoicing` | Invoice / payment / refund / chargeback operations. |
| `/v1/processing/invoice_templating` | `dmsl_payproc_thrift:InvoiceTemplating` | Invoice template lifecycle + term computation. |
| `/v1/stateproc/<namespace>` | `mg_proto_state_processing_thrift:Processor` | Machine processor callback invoked by Machinegun. |
| `/v1/proxyhost/provider` | `dmsl_proxy_provider_thrift:ProviderProxyHost` | Provider-facing host callback API (`ProcessPaymentCallback`, `GetPayment`, session updates). |

The Progressor backend replaces the `/v1/stateproc/...` Machinegun callback
with a Progressor-native `Processor` callback served by
[`hg_progressor_handler.erl`](../apps/hg_progressor/src/hg_progressor_handler.erl).

## Namespaces

Each kind of machine has a dedicated namespace (the `namespace/0` callback of
the `hg_machine` behaviour). The most important ones are:

- `invoice` — an invoice and its nested payments, refunds, chargebacks
- `invoice_template` — reusable invoice templates
- `recurrent_paytools` — tokenised payment methods for recurrent billing

Callbacks from providers are routed to `invoice` machines through a
tag-to-machine binding stored in
[`hg_machine_tag`](../apps/hellgate/src/hg_machine_tag.erl).
210 changes: 210 additions & 0 deletions doc/domain-and-party.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# Domain, party and varset

Hellgate is stateless with respect to configuration: every decision that
depends on merchant settings, provider terms, fees, limits, routing rules,
available payment methods or acceptable currencies is resolved against the
**domain**, a versioned configuration store owned by DMT. This page
documents how that lookup works and which hooks feed it.

## Domain (DMT)

Module: [hg_domain.erl](../apps/hellgate/src/hg_domain.erl).

All domain access flows through `hg_domain:get/2`:

```erlang
get(Revision, Ref) ->
try extract_data(dmt_client:checkout_object(Revision, Ref))
catch throw:#domain_conf_v2_ObjectNotFound{} ->
error({object_not_found, {Revision, Ref}})
end.
```

- `Revision` is either the symbolic `latest` or a concrete integer version.
Hellgate *pins* a revision at the beginning of a payment flow and passes
it through the whole call chain — routing, term evaluation and
accounting all use the same revision so a config change mid-payment
cannot corrupt the outcome.
- `Ref` is one of the domain reference tuples: `{party_config, …}`,
`{shop_config, …}`, `{provider, ProviderRef}`, `{terminal, TerminalRef}`,
`{proxy, ProxyRef}`, `{limit_config, …}`, `{category, …}`,
`{currency, …}`, `{payment_institution, …}`, `{inspector, …}`, and so on.
- `dmt_client` is the shared DMT RPC client; Hellgate does not cache
outside of its short-lived per-request context.

Every event that records a decision tied to the domain (route selection,
cash flow, limits) also records the revision used, so the entire decision
can be reconstructed deterministically.

## Party and shop

Module: [hg_party.erl](../apps/hellgate/src/hg_party.erl).

A **party** owns one or more **shops**. Both are addressed by
`party_config_ref()` and `shop_config_ref()` respectively and stored as
domain objects:

```erlang
get_party(PartyConfigRef) ->
checkout(PartyConfigRef, get_party_revision()).

get_shop(ShopConfigRef, PartyConfigRef, Revision) ->
try dmt_client:checkout_object(Revision, {shop_config, ShopConfigRef}) of
#domain_conf_v2_VersionedObject{
object = {shop_config, #domain_ShopConfigObject{
data = #domain_ShopConfig{party_ref = PartyConfigRef} = ShopConfig
}}
} ->
{ShopConfigRef, ShopConfig};
_ -> undefined
catch throw:#domain_conf_v2_ObjectNotFound{} -> undefined
end.
```

Notice that `get_shop/3` validates that the shop belongs to the given
party — this is the main cross-check that keeps one party from touching
another's shop by guessing its ID.

Party objects carry:

- Owner metadata and contact details
- A list of shops
- Contract terms and KYC status
- Suspension and activation state
- Blocking status (fraud, AML, etc.)

Shops carry their own set of turnover limits, category, currency,
accepted payment tools and account references. Most of the per-merchant
behaviour a payment will see is ultimately sourced from the shop config.

### Operability checks

Before doing anything that mutates money, Hellgate asserts via
[`hg_invoice_utils`](../apps/hellgate/src/hg_invoice_utils.erl) that the
party and shop are *operable* — not blocked, not suspended, contract
active. A failing check aborts the operation with a clear error instead
of creating a dangling machine.

## Varset

Module: [hg_varset.erl](../apps/hellgate/src/hg_varset.erl).

The varset is a small map of the variables the domain uses to reduce
selectors. Think of it as the "question" we're asking the domain. It is
assembled as the payment progresses, with later stages adding more keys:

```erlang
-type varset() :: #{
category => dmsl_domain_thrift:'CategoryRef'(),
currency => dmsl_domain_thrift:'CurrencyRef'(),
cost => dmsl_domain_thrift:'Cash'(),
payment_tool => dmsl_domain_thrift:'PaymentTool'(),
party_config_ref => dmsl_domain_thrift:'PartyConfigRef'(),
shop_id => dmsl_base_thrift:'ID'(),
risk_score => hg_inspector:risk_score(),
flow => instant | {hold, dmsl_domain_thrift:'HoldLifetime'()},
wallet_id => dmsl_base_thrift:'ID'()
}.
```

When it is handed to DMT, `prepare_varset/1` converts it into the Thrift
`#payproc_Varset{}` struct DMT selectors evaluate against.

### Where the varset drives behaviour

- **Routing** (`hg_routing:gather_routes/5`): filters routing rules and
prohibitions, producing the candidate list.
- **Term resolution**: fees, 3DS requirements, allowed payment methods,
hold lifetimes and other per-operation rules are selected from the
party/shop/provider terms against the varset.
- **Payment institution resolution**
(`hg_payment_institution:compute_payment_institution/3`): picks system
and external accounts by currency and varset.
- **Inspector**: the inspector is selected from the domain using the same
varset, so a shop can use different risk engines for different
categories or payment tools.

The varset is the single bottleneck through which every "what does
config say here?" question in Hellgate has to pass. This is the reason a
design change that adds, say, a new routing dimension starts with a new
varset key.

```mermaid
flowchart LR
I[Invoice + Payer] --> V[varset]
R[(domain revision)] --> V
RS[risk_score] --> V
V --> PI[payment institution<br/>reduction]
V --> T[term selectors<br/>fees, 3DS, limits]
V --> RT[routing rules<br/>+ prohibitions]
V --> ACC[external account<br/>selection]
```

> [!IMPORTANT]
> The varset is cumulative: later stages add keys. Earlier stages must
> not depend on keys that are only filled in later (e.g. routing has a
> `risk_score` because the inspector runs first; it does **not** have a
> `provider_ref` because routing is what sets it).

## Payment institution

Module: [hg_payment_institution.erl](../apps/hellgate/src/hg_payment_institution.erl).

A payment institution is the top-level config blob for "a way of
accepting payments" — typically one per legal entity / licence / scheme.
It owns:

- Routing rules (policies + prohibitions)
- Default cash flow postings
- System account references per currency
- External account sets (selected by varset)
- Inspector and proxy references

`compute_payment_institution/3` reduces the referenced payment
institution against the varset and returns the concrete struct used by
routing, term resolution and accounting. Any per-request domain
variability lives inside that reduction; downstream code just sees the
resolved values.

## Payment tools

Module: [hg_payment_tool.erl](../apps/hellgate/src/hg_payment_tool.erl).

Thin helper to extract the `PaymentTool` from a `Payer` variant (direct
card, recurrent token, payment terminal, digital wallet, crypto, etc.).
The payment tool is what enters the varset under `payment_tool` and what
the provider adapter ultimately consumes.

## Request context

Module: [hg_context.erl](../apps/hellgate/src/hg_context.erl).

Per-request auxiliary data (Woody deadline, trace id, party client,
domain revision, current log scope) is stashed in a small record kept in
the process dictionary via `save/1`, `load/0`, `cleanup/0`. Long-running
call chains (especially repair and the Progressor processor callback)
save-and-cleanup around the handler to keep request scopes from leaking.

## Putting it together

A concrete example of how party + DMT + varset come together on
`CreatePayment`:

1. The handler resolves the party and shop from the invoice
(`hg_party`) and asserts they are operable.
2. It builds an initial varset from the invoice and the payer.
3. It pins the current domain revision.
4. It calls the inspector (`hg_inspector`) to get a risk score; the
score goes into the varset.
5. It resolves the payment institution
(`hg_payment_institution:compute_payment_institution/3`) and reduces
routing rules against the varset.
6. Routing (`hg_routing`) produces a candidate list; cash flow
(`hg_cashflow`) is reduced against the same varset once a candidate
is chosen.
7. Terms (fees, limits) are reduced against the same varset before
limits are held and the provider call is issued.

The same revision + varset pair is threaded through every subsequent
state transition, so replaying a payment's history is deterministic even
if the domain has moved on.
Loading
Loading