# Mollie integration

How Kulibri uses Mollie for the Premium membership: the objects, the payment lifecycle, every code path that changes a subscription, and every email that goes out because of it.

Written from reading the code (no production data). Statements marked **(verify)** are inferred and should be checked against the live Mollie account or logs before relying on them.

## 1. Where the code lives

| What | Where |
|---|---|
| Client + webhook URLs | `app/Classes/Mollie.php` (`Mollie::initialize()`), API key from `MOLLIE_KEY` → `config('app.mollie_key')` |
| HTTP endpoints | `app/Http/Controllers/PaymentController.php` |
| Subscription logic (shared) | `app/Classes/CheckAndCreateSubscription.php` |
| Daily expiry / downgrade job | `app/Console/Commands/ProcessCanceledSubscriptions.php` (`process:subscriptions`) |
| Ops / audit commands | `SyncMollieInvoices`, `VerifyMollieSubscriptions`, `CheckMollieSubscriptions` (see §8) |
| Status values | `app/Enums/SubscriptionStatusEnum.php` |
| Models | `Membership`, `Subscription`, `Invoice`, `User` (`customer_id`, `roles`) |
| Tests | `tests/Feature/PaymentWebhookTest.php` (the Mollie client is bound in the container by `AppServiceProvider` so tests can substitute it) |

The webhook URLs sent to Mollie are built from `config('app.frontend_url')`: `…/api/payment/webhook` and `…/api/subscription/webhook`.

## 2. Data model in one paragraph

A `User` gets one Mollie **customer** (`users.customer_id`). Each purchase creates a local `Subscription` row (`user_id`, `membership_id`, `payment_id`, `subscription_id`, `status`, `cancel_status`, `start_date`, `end_date`, `mail_sent`, `payment_response`, `cancel_response`). `payment_id` is the **latest Mollie payment** (`tr_…`), `subscription_id` is the **Mollie recurring subscription** (`sub_…`). Every successful payment adds an `Invoice`. Premium access is bit 1 of `users.roles` (`roles & 1`); `256` and `0` are the "no premium" values that get `+1` on activation (see §7).

Two different statuses exist and are easy to confuse:

- `subscriptions.status` – status of the **latest payment** (`open`, `paid`, `failed`, `canceled`, `expired`, …).
- `subscriptions.cancel_status` – set when the **subscription was ended** (`canceled`), or to the payment status when the first payment failed/was canceled.

`subscriptions.mail_sent` is a boolean column by migration but is used as a small counter: `0` = nothing sent, `1` = payment confirmation sent, `2` = downgrade warning sent (see §6).

## 3. Lifecycle

```
User clicks "buy"
   │  POST /api/subscription/{user_id}                 createPayment()
   ▼
Mollie customer created if missing; local Subscription row created;
"first" payment created (sequenceType=first) → user is redirected to Mollie
   │
   │  user pays (or fails / cancels) at Mollie
   ▼
Mollie → POST /api/payment/webhook                     paymentWebhook()
User returns to /membership?order_id={subscription id}
   │  POST /api/subscribe/{subscription_id}            getPaymentDetailsBySubscriptionID()
   ▼
checkStatusAndMakeSubscription(): reads payment; if paid and no sub_ yet →
createSubscription() creates the Mollie recurring subscription (uses the
mandate of the first payment), first Invoice is created,
PaymentConfirmationMail is sent, roles += 1
   │
   │  every interval Mollie charges the mandate
   ▼
Mollie → POST /api/payment/webhook (payment paid / failed / canceled)
   paid  → end_date = nextPaymentDate, new Invoice, roles += 1 if needed
   failed/canceled → see §5
```

Notes:

- The **first** payment carries `metadata.subscription_id`. Recurring payments do not, so the webhook falls back to `payment->subscriptionId` (Mollie's `sub_…`) to find the local row.
- The recurring subscription is created **after** the first payment is `paid`, by whichever of these runs first: the browser return call (`/api/subscribe/{id}`) or the membership page load (`getUserPaymentInformation`, for payments still `open`).
- Membership price comes from `Membership.price + vat_amount`, interval from `Membership.interval` (`12M`, `1M`, trial `14D`). Trials (`startTrial`) never touch Mollie: they are local `Subscription` rows with `status=paid`, a 14-day `end_date`, and no `subscription_id`.

## 4. Endpoints

| Route | Auth | Purpose |
|---|---|---|
| `POST /api/subscription/{user_id}` | sanctum + verified | Create customer, local subscription, first payment |
| `POST /api/subscribe/{id}` | sanctum + verified | After redirect: read payment, create Mollie subscription, confirmation mail |
| `GET /api/user/{id}/payment` | sanctum + verified | Membership page data; also reconciles with Mollie (§5, path B) |
| `POST /api/user/{id}/cancel-subscription` | sanctum + verified | **User-initiated cancel** |
| `POST /api/start-trial/{user_id}`, `DELETE /api/delete-trial/{user_id}` | public poll group (`hashids`,`logger`) **(verify auth)** | Trial start/end, no Mollie |
| `POST /api/payment/webhook`, `POST /api/webhook` (old customers) | none (Mollie) | Payment status changes |
| `POST /api/subscription/webhook` | none (Mollie) | Currently only stores the request body via `Storage::put`, no processing |
| `GET /api/get-subscription`, `GET /api/sync-invoice` | none | Admin/diagnostic helpers **(verify they are safe to leave open)** |
| `GET /getPayment` (web) | none | `updateLatestPaymentStatus`, bulk downgrade helper |
| `GET /sent-mail/{email}` (web) | none | Sends a payment confirmation mail to any registered address |

The webhooks are unauthenticated by design (Mollie has no signature). The handler therefore re-reads the payment from the Mollie API by id and never trusts the posted body, which is the correct pattern; keep it that way. Every webhook body is also written to `storage/app/{timestamp}.json`.

## 5. What ends a subscription (and mails the user)

`CheckAndCreateSubscription::cancelSubscriptionAction()` is the single place where a subscription is cancelled at Mollie and the user is mailed. It:

1. finds the user's last subscription;
2. if the user has no `customer_id`: marks it `canceled`, removes the premium role, returns (**no mail**);
3. otherwise cancels the recurring subscription at Mollie (or notes it is already cancelled), sets `cancel_status`, stores `cancel_response`;
4. sends **`SubscriptionCancelationConfirmationMail`** to the user (bcc support@kulibri.com);
5. sends a plain "Subscription Cancelled" notice to Andi (bcc two developers).

It is called from **three** places, only the first of which is a user cancel:

| | Trigger | Condition |
|---|---|---|
| **A. User cancels** | `PaymentController::cancelSubscription` (`POST /user/{id}/cancel-subscription`) | always |
| **B. Mollie already cancelled** | `CheckAndCreateSubscription::getUserPaymentInformation` (membership page load) | local subscription `paid`, `end_date` ≤ today, Mollie says `canceled`, local `cancel_status` not yet `canceled`. Happens when Mollie ends the subscription itself (e.g. after repeated failed charges) or someone cancels in the Mollie dashboard |
| **C. Failed/cancelled payment webhook** | `PaymentController::paymentWebhook`, block "Handle failed/canceled payments" | payment `status` is `canceled` or `failed` **and** the local `end_date` ≤ today (i.e. a renewal that could not be collected) |

Consequence: **B and C send the user a "cancelation confirmation" although the user did not cancel.** The text is identical to path A. There is no separate wording for "your payment failed". If this is not wanted, the fix is to give B/C their own mail (or none) and keep the confirmation for A only.

A failed/cancelled **first** payment (not past `end_date`, because there is none yet) only records `status` and, through `checkStatusAndMakeSubscription`, `cancel_status`; no mail.

Idempotency: the webhook skips a payment when `subscription.payment_id === payment id` (duplicate delivery). Because path C does not update `payment_id` before a second, different failed payment arrives, a user can receive the mail more than once **(verify against logs)**.

## 6. All emails caused by this integration

| Mail | Subject (de) | Sent when | Code |
|---|---|---|---|
| `PaymentConfirmationMail` | (see its translation key) | First successful payment, once (`mail_sent == 0` → `1`) | `getPaymentDetailsBySubscriptionID` |
| `SubscriptionCancelationConfirmationMail` | **Premium Mitgliedschaft Kündigung** | Paths A, B, C above | `CheckAndCreateSubscription::sendSubscriptionCancelationConfirmationMail` |
| `DowngradeNotificationMail` | **Premium Mitgliedschaft Kündigung** (same subject, key `subjectDowngradeNotification`) | Daily 07:30: subscription expired more than `THRESHOLD_IN_DAYS_FOR_PREMIUM` days ago (default 1), `mail_sent < 2`, user still premium, no newer subscription. Skipped if Mollie shows it was extended. Sets `mail_sent = 2` | `ProcessCanceledSubscriptions::notifyUsersAboutDowngrade` |
| `LegacyDowngradeNotificationMail` | same | Not sent: the `$legacy` flag is always `false` | – |
| Admin "expired subscriptions" | expired subscriptions | `notifyAdmin()` in the daily job (support@) | `ProcessCanceledSubscriptions` |
| `SubscriptionAdminNotificationMail` | `[Kulibri] New Premium subscription: <email>` / `Premium subscription renewed: <email>` / `Premium renewal payment FAILED: <email>` | To `MAIL_SUBSCRIPTION_ALERT` (comma-separated, falls back to `MAIL_SUPPORT_ADDRESS`). **New**: first payment paid and Mollie subscription created (`checkStatusAndMakeSubscription`, after commit, once). **Renewal**: recurring payment paid (webhook, right after the invoice). **Failed**: recurring payment failed/canceled (webhook). Not sent for trials, back-fills, or a failed/canceled *first* payment | `App\Services\SubscriptionAdminNotifier` |
| Admin "Subscription Cancelled" | Subscription Cancelled | Every `cancelSubscriptionAction` success | hard-coded addresses in `CheckAndCreateSubscription` |
| Admin error mail | Mollie API Error / Payment Webhook Error | Webhook throws | `PaymentController::sendErrorEmail`, hard-coded addresses |

The two "Premium Mitgliedschaft Kündigung" mails share the translation keys `subscriptionCancalation` / `subjectDowngradeNotification` in `resources/lang/{de,en}.json` (note the typo in the key). A user reporting this subject may have received either one: the confirmation (§5) or the expiry warning ("in N days you lose premium").

## 7. Roles and downgrade

- **Grant**: `roles += 1` when a subscription is `paid` and the user has `roles` of `0` or `256`: in `getPaymentDetailsBySubscriptionID`, in the paid-webhook branch, and at the end of `getUserPaymentInformation`.
- **Revoke** (daily job): `findSubscriptionsToBeDowngraded` picks subscriptions with `mail_sent = 2` whose `end_date` is more than `DOWNGRADE_AFTER_DAYS - 1` days old (default 3) and calls `User::downGradeUserRole()` (clears bit 1, also removes sub-admins). So the timeline for an unpaid renewal is roughly: expiry → warning mail after `THRESHOLD_IN_DAYS_FOR_PREMIUM` days → premium removed ~`DOWNGRADE_AFTER_DAYS` days after expiry.
- **Immediate revoke**: user cancel with no `customer_id`, and `deleteTrial`, subtract 1 directly.
- Cancelling in Mollie does **not** remove premium immediately; the user keeps it until `end_date` plus the grace above.

## 8. Operations

| Command | Use |
|---|---|
| `php artisan process:subscriptions` | The daily job (scheduled 07:30 in `app/Console/Kernel.php`, log `storage/logs/cancled-subscriptions.log`). Syncs status/payments with Mollie, warns, downgrades |
| `php artisan mollie:sync-invoices [--dry-run] [--user=ID]` | Create missing invoices and fix `end_date` from Mollie payments |
| `php artisan subscriptions:verify-mollie [ids] [--from-db\|--all\|--file=] [--csv=]` | Audit live Mollie subscription status per user, CSV export |
| `php artisan mollie:check-csv {file} [--last-months=N]` | Reconcile a Mollie CSV export against the DB, both directions |

Config: `MOLLIE_KEY`, `MAIL_SUBSCRIPTION_ALERT`, `MEMBERSHIP_NAME` (default `premium`), `THRESHOLD_IN_DAYS_FOR_PREMIUM` (1), `DOWNGRADE_AFTER_DAYS` (3). The Mollie key is per environment; Mollie needs to reach `frontend_url` from the internet for webhooks, so local development needs a tunnel or manual `getPayment`-style reconciliation.

Debugging a single user: look for these log lines in `storage/logs`:

- `PaymentController::paymentWebhook - going to cancel the subscription` → path C
- `so cancle the current subscription` → path B
- `Subscription cancelled by <email>` mail / `user started cancellation` → path A
- `Subscription for user … got extended, so skipping the downgrade notification` → warning suppressed

## 9. Known issues / open points

1. **Misleading cancel mail** on automatic paths B and C (§5).
2. `subscriptionWebhook` (`/api/subscription/webhook`) is a stub that only dumps the request to disk. Mollie subscription-level events are therefore not processed; state is only refreshed via the payment webhook, the membership page, and the daily job.
3. `mail_sent` is a boolean column used as 0/1/2 (works on MySQL, would break on stricter DBs).
4. Hard-coded recipient addresses (support@, Andi, two developers) in `CheckAndCreateSubscription` and `PaymentController`.
5. Web routes `/sent-mail/{email}` and `/getPayment`, and API routes `/get-subscription`, `/sync-invoice`, are unauthenticated and act on real data; review them alongside `SECURITY-BACKLOG.md`.
6. `createPayment` and `createSubscription` build the amount from `explode('.')` and multiply the decimals by 10 when `< 10`. For a total whose cents are `05` (e.g. `36.05`) that yields `36.50`, and the same for `01`–`09`. Current prices (`36.00`) are unaffected, but the logic is wrong; `number_format` output can be used as is. **(verify with a unit test before changing)**
7. `updateLatestPaymentStatus` decrements `roles` without the guards used elsewhere, and `roles -= 1` can produce wrong values for users without the premium bit.
8. Test coverage: `paymentWebhook` and the admin notifications (`SubscriptionAdminNotificationTest`, incl. `checkStatusAndMakeSubscription` on the new-subscription path) are covered; cancel paths A/B and the daily job are not (track in `TESTING-BACKLOG.md`).
9. A first payment is only activated when the user's browser returns (`/api/subscribe/{id}`) or they open the membership page: its own webhook is always treated as a duplicate (§3). The "new subscription" admin mail therefore arrives at that moment, not when Mollie confirms the payment.
10. `Subscription::$fillable` does not include `payment_id` / `subscription_id`; they are always set by property assignment. Mass-assigning them silently does nothing (tests use `forceFill`).
