# Callback Processing

Operator (DCB) notifications land on `GET /api/callback` (`CallbackController`), are stored immediately, then processed asynchronously by `ProcessCallbackJob`.

Subscriber status writes match World Cup `dcb_subscribers`: SUB parks as `inactive`, RENEWAL is what activates, OOB parks as `inactive`, UNSUB is `unsubscribed`.

Send PIN, verify PIN, and the HE success page do **not** create or update `subscribers`. Local rows appear only from callbacks.

```
Operator → GET /api/callback → callbacks row (status=pending)
                              → ProcessCallbackJob
                              → subscriber status + PostHog events
```

---

## Callback record statuses (`callbacks.status`)

| Status | Set by | Meaning |
|---|---|---|
| `pending` | `CallbackController` on insert | Queued, job has not started |
| `processing` | Job `handle()` start | Job picked up the row |
| `completed` | Job after a known action handler returns | Handler ran without throwing |
| `failed` | Job on a handled error or exception | See failure paths below |

Legacy rows written during the World Cup alignment attempt may still have `processed`. The job treats `completed` and `processed` as already done and skips them.

If the row cannot be loaded, the job logs and exits.

### Failure paths (`status = failed`)

| Cause | `error_message` | PostHog |
|---|---|---|
| No `services` row for `callback.service_id` | `Service not found for service_id: …` | `callback_failed` (`service_not_found`) |
| Unknown `action_type` (not 0–3) | `Unknown action type: …` | `callback_failed` (`unknown_action`) |
| Uncaught exception in a handler | Exception message | `callback_failed` (`exception`) |

`processed_at` is set on `completed` and `failed`. Success also clears `error_message`.

---

## Action types (`callbacks.action_type`)

| Value | Constant | Label | Subscriber effect |
|---|---|---|---|
| `0` | `ACTION_UNSUB` | `unsub` | Existing row → `unsubscribed` + `unsubscribed_at=now`. No create. |
| `1` | `ACTION_SUB` | `sub` | Upsert `inactive`, reset `subscribed_at`, clear `unsubscribed_at` |
| `2` | `ACTION_RENEWAL` | `renewal` | Upsert `active`, set `last_billing_date` + `last_active_at`, keep original `subscribed_at` |
| `3` | `ACTION_OUT_OF_BALANCE` | `out_of_balance` | Existing row → `inactive`. No create. Do not set `unsubscribed_at`. |

MSISDNs are normalized to digits with a `964` prefix before any subscriber lookup.

---

## Subscriber statuses (`subscribers.status`)

Three states:

| Status | Meaning | Written by |
|---|---|---|
| `inactive` | Opted in, not billed (or charge failed) | SUB, OOB |
| `active` | Successful charge | RENEWAL |
| `unsubscribed` | Left the service | UNSUB |

`isActive()` is true only for `active`. Dashboard “Active Subscribers” counts that value only.

---

## Case: `ACTION_SUB` (1)

Always upserts. Does not skip an already-active row (a new cycle resets the record).

| Local record | Result |
|---|---|
| Missing | Create `inactive` |
| Exists | Set `inactive`, reset `subscribed_at`, clear `unsubscribed_at` |

PostHog: `subscription_success`.

If `service.is_smart` is true, request a session and SMS the local portal URL.

---

## Case: `ACTION_UNSUB` (0)

| Local record | Result |
|---|---|
| Missing | No create. Log + PostHog `subscription_failed` |
| Exists | `unsubscribed` + `unsubscribed_at=now` |

---

## Case: `ACTION_RENEWAL` (2)

Successful charge. This is the activation path.

| Local record | Result |
|---|---|
| Missing | Create `active` with `subscribed_at`, `last_billing_date`, `last_active_at` |
| Exists | Force `active`, set `last_billing_date` + `last_active_at`, keep original `subscribed_at`, clear `unsubscribed_at` |

PostHog: `billing_success` and `renewal_success`.

---

## Case: `ACTION_OUT_OF_BALANCE` (3)

Charge failed. Still in the product — not an unsubscribe.

| Local record | Result |
|---|---|
| Missing | No create. Log warning |
| Exists | Set `inactive`. Leave `unsubscribed_at` untouched. Do not set `last_billing_date` |

PostHog: `billing_failed` (`response_code=out_of_balance`).

The callback row is then marked `completed`.

---

## Tracking map

| Event | When |
|---|---|
| `callback_sent` | HTTP receive (`CallbackController`) |
| `callback_success` | Job finished a known action |
| `callback_failed` | Service missing, unknown action, or exception |
| `subscription_success` | SUB upserted |
| `subscription_failed` | UNSUB for an MSISDN with no local record |
| `billing_success` + `renewal_success` | RENEWAL |
| `billing_failed` | OUT_OF_BALANCE |

Callback PostHog visitor IDs are `cb-` + SHA-256 of `msisdn + service_id`. MSISDNs in tracking payloads are masked.
