# Peptidewinkel backend (Laravel)

This is the Laravel application that replaces the Supabase project the website
used to talk to. It owns the technical database, exposes the internal API the
website's server runtime calls, and serves the built front end.

Customer accounts, orders, products and payments stay where they already were:
WordPress + WooCommerce remains the system of record. This backend only stores
the technical records the Supabase migrations held.

## Requirements

- PHP 8.2+ with `pdo`, `mbstring`, `openssl`, `curl` (and `pdo_mysql` /
  `pdo_pgsql` for production)
- Composer 2
- MySQL 8+ or PostgreSQL 13+ in production (SQLite works for local development)

## Setup

```bash
cd backend
composer install
cp .env.example .env          # Windows: copy .env.example .env
php artisan key:generate
php -r "echo bin2hex(random_bytes(32));"   # → PW_BACKEND_KEY
php artisan migrate
```

Point the database at MySQL or PostgreSQL by editing `.env`:

```dotenv
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=peptidewinkel_backend
DB_USERNAME=...
DB_PASSWORD=...
```

### Required environment variables

| Variable | Purpose |
| --- | --- |
| `PW_BACKEND_KEY` | Shared secret the website signs internal API calls with (`X-Backend-Key`). Must match the front end. |
| `PW_ASSISTANT_DAILY_LIMIT` | Default AI assistant questions per customer per day (the front end used 10). |
| `CORS_ALLOWED_ORIGINS` | Comma separated origins allowed to call the API from a browser. |
| `PW_SSR_RUNTIME_URL` | URL of the website's JavaScript runtime. When set, the backend forwards every non-API request to it (see "Serving the front end"). |

## Running

```bash
php artisan serve                # http://127.0.0.1:8000
php artisan schedule:work        # local scheduler (replaces pg_cron)
php artisan test
```

## Settings and credentials

Everything the backend needs — WooCommerce keys, the WordPress JWT secret, the
SliceWP bridge secret, both payment gateways and the routing rules — is editable
at runtime, without a deploy:

- **Admin screen:** `/admin/settings` (HTTP Basic auth from `PW_ADMIN_USER` /
  `PW_ADMIN_PASSWORD`). Secret fields are shown masked, and an empty field means
  "keep the stored value".
- **Command line:** `php artisan settings list`, `settings set <key> [value]`,
  `settings forget <key>`.
- **API:** `GET /api/internal/settings` (masked) and `PUT /api/internal/settings`.

The catalogue of known keys lives in `App\Services\Settings\SettingDefinitions`,
so an unknown key can never be written. Resolution order is:

```
settings table  →  config/peptidewinkel.php (the .env fallbacks)  →  default
```

Secrets are encrypted at rest with the application key and only decrypted
server-side; the API and the admin screen only ever expose a masked preview. If
`APP_KEY` is rotated, stored secrets become unreadable and fall back to unset —
re-enter them in `/admin/settings`.

## Payment gateways and routing

Two gateways are wired up, and the backend decides per payment which one handles it:

| Gateway | Auth | Methods | Outcome |
| --- | --- | --- | --- |
| **ConfluxAPI** | `X-App-Id` / `X-Timestamp` / `X-Nonce` / `X-Signature` (HMAC-SHA256, base64) | iDEAL, Bancontact, Klarna | Webhook + status query fallback |
| **Gyvo** (Stripe-backed) | HTTP Basic (`cus_…` / `sec_…`) | card, iDEAL, Bancontact, Klarna, SEPA, PayPal… | Polling (`GET /api/v1/payments/{id}`) |

Gyvo has no webhook towards us — Stripe notifies Gyvo — so its payments are
resolved by polling, which is what `POST /api/internal/payments/sync` does.

### Routing rules

`payment.routing.rules` is an ordered JSON list; the first matching rule wins. A
rule may constrain `methods`, `countries`, `min_amount` / `max_amount` (in cents)
and may carry a `percentage` to split traffic between two live gateways.

```json
[
  { "gateway": "gyvo",    "methods": ["card", "sepa_debit", "paypal"] },
  { "gateway": "conflux", "methods": ["ideal", "bancontact"], "countries": ["NL", "BE"] },
  { "gateway": "gyvo",    "min_amount": 50000 },
  { "gateway": "conflux", "percentage": 70 },
  { "gateway": "gyvo" }
]
```

After the primary gateway is chosen, `payment.routing.fallback_gateways` (for
example `gyvo,conflux`) is appended as alternative candidates. A gateway is
skipped when it is not configured or does not support the method, and the next
candidate is tried when a provider refuses (see `failed_attempts` in the
response). Amounts are always read from the WooCommerce order, never from the
storefront.

### Where the provider credentials live

Payment creation, gateway routing, status queries **and** notification
verification all run here. The site runtime no longer needs any Conflux
credential: its webhook route forwards the untouched body to
`POST /api/internal/payments/webhook/conflux`, which verifies the signature with
the stored app secret, claims the `notify_id` idempotently
(`conflux_webhook_events`), mirrors the outcome onto the order and returns the
order fields the storefront needs for its loyalty side effects.

In production, add one cron entry so the Supabase pg_cron jobs keep running:

```
* * * * * cd /path/to/backend && php artisan schedule:run >> /dev/null 2>&1
```

## API

All routes below are implemented in `routes/api.php`.

| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/health` | Public liveness probe (app + database). |
| POST | `/api/internal/webhook-events/conflux` | Conflux claim (`notify_id`; shared with the status-polling fallback). |
| POST | `/api/internal/loyalty-events/claim` | Claim a loyalty idempotency lock → `{ "claimed": bool }`. |
| POST | `/api/internal/loyalty-events/exists` | Read-only lock check → `{ "exists": bool }`. |
| POST | `/api/internal/assistant/claim-question` | Claim one AI question; `429` + `used: -1` when the daily limit is spent. |
| GET | `/api/internal/assistant/usage` | Current usage without consuming a question. |
| GET | `/api/internal/stats` | Row counts + latest timestamps per technical table. |
| GET | `/api/internal/settings` | Settings overview (secret values masked). |
| PUT | `/api/internal/settings` | Update settings; unknown keys are rejected. |
| POST | `/api/internal/payments` | Start a payment for an order; the router picks Conflux or Gyvo. |
| POST | `/api/internal/payments/sync` | Resolve the payment at the provider and mirror it onto the order. |
| GET | `/api/internal/payments/gateways` | Configured gateways, their methods and the active routing. |
| GET | `/api/internal/payments/order/{order}` | Stored payment of an order (requires its order key). |
| POST | `/api/internal/payments/webhook/conflux` | Verifies, claims and applies a Conflux notification (the runtime forwards the raw body). |

Everything under `/api/internal` requires the `X-Backend-Key` header (or a
`Bearer` token with the same value) and answers `503` when `PW_BACKEND_KEY` is
not configured, `401` when the key does not match.

Example:

```bash
curl -X POST http://127.0.0.1:8000/api/internal/assistant/claim-question \
  -H "X-Backend-Key: $PW_BACKEND_KEY" \
  -H "Content-Type: application/json" \
  -d '{"customer_id":42,"limit":10}'
```

## Where the Supabase migrations went

| Supabase source | Laravel equivalent |
| --- | --- |
| `loyalty_events` table + unique lock | `2026_06_10_121558_…` + `App\Models\LoyaltyEvent` + `App\Services\LoyaltyEventLedger` |
| `conflux_webhook_events` table | `2026_08_12_133559_…` + `App\Models\ConfluxWebhookEvent` |
| `paytree_webhook_events` and `liasonpay_webhook_events` tables | Removed: Paytree and LiasonPay were replaced by Conflux and Gyvo. `2026_10_14_210000_drop_paytree_and_liasonpay_webhook_events_tables` drops them. |
| `assistant_usage` table | `2026_10_04_200017_…` |
| `claim_assistant_question(bigint, integer)` | `App\Services\AssistantQuotaService::claim()` |
| `drizzle/migrations/0000_migration.sql` (null raw payloads) | `webhooks:scrub-raw-payloads` |
| RLS "deny anon/authenticated" policies | No public routes exist; `/api/internal` is key-protected |
| `gen_random_uuid()` primary keys | `App\Models\Concerns\HasUuidPrimaryKey` (UUID v4) |
| `jsonb` columns | `jsonb` on PostgreSQL, `json` on MySQL/SQLite (`App\Support\SchemaSupport`) |
| `extensions` schema + `pg_net` / `pg_cron` | Not ported: those are Supabase platform plumbing. Scheduling is `routes/console.php`, outbound HTTP is Laravel's HTTP client. |
| Supabase `service_role` grants | Replaced by the `pw.internal` middleware (shared secret). |
| — (new) `settings` table | Credential store with encrypted secrets, editable in `/admin/settings`. |
| — (new) `payments` + `payment_events` | Payment attempts and idempotent status transitions for the gateway routing. |

### Fidelity notes

- Index names match the Supabase ones (`loyalty_events_unique`,
  `conflux_webhook_events_notify_unique`, …) so monitoring and the migration docs
  line up.
- `received_at` DESC indexes and the partial `loyalty_events_order_idx … WHERE
  order_id IS NOT NULL` index exist on PostgreSQL/SQLite. MySQL has no partial
  indexes, so it gets a plain index on `order_id`.
- MySQL cannot index a `TEXT` column without a key length, so columns that take
  part in a unique key become `VARCHAR(255)` on MySQL and stay `TEXT` on
  PostgreSQL/SQLite (`SchemaSupport::textColumn()`).
- PostgreSQL keeps `gen_random_uuid()` as the column default, so rows inserted by
  any other tool still get a UUID v4 exactly like before.
- `raw_payload` is accepted by nobody: the request validators reject it with
  `prohibited`, mirroring the rule that provider payloads (which may contain
  customer data) are never stored.

## Serving the front end

The website is a TanStack Start app whose server functions hold the WooCommerce,
WordPress, checkout and payment-provider logic. `SpaController` supports two
layouts:

**A. The runtime stays in charge of pages (recommended).** Set
`PW_SSR_RUNTIME_URL` to the website runtime (`npm run build:server` +
`node .output-server/server/index.mjs`, or `npm run dev:all` locally). Laravel
answers `/api/*` itself and forwards everything else there, so pages keep being
server-rendered (good for SEO) and server functions keep working. The runtime can
run on Cloudflare, Node or a container next to Laravel. If it is unreachable, page
requests fall back to the published shell instead of showing an error.

**B. Laravel serves the built client bundle.** Build and publish once:

```bash
npm install
npm run build:laravel      # PW_SPA_BUILD=1 vite build + php artisan spa:publish
```

That prerenders the shell, copies the hashed assets to `public/assets` (served at
`/assets/...`) and the shell to `public/shell.html`; every deep link then answers
with the shell and the router takes over in the browser. The prerendered shell is a
snapshot of the homepage, so the browser logs a React hydration warning (#418) and
crawlers see that snapshot on every URL — which is why mode A (see README.md) is
the recommended setup. Server functions (`/_serverFn/*`) and the site's own webhooks
(`/api/public/*`) are still forwarded to
`PW_SSR_RUNTIME_URL`, so set it as well if you want checkout, login and payment
callbacks to work. Without a runtime and without a published bundle, the site
answers 503 with setup instructions instead of a blank page.

`php artisan spa:publish --source=… --force` does the copying on its own if you
build elsewhere.

The original `supabase/` directory is kept in the repository as the reference for
this port (the mapping table above points at it). Once the existing rows have been
loaded into the Laravel database it can be deleted.

## Tests

```bash
php artisan test      # 50 tests: schema fidelity, internal API, retention jobs,
                      # quota semantics and front-end forwarding
```
