# CashLoop — Reward Platform Admin Panel & API

A GPT ("get-paid-to") reward platform: users earn real money by installing apps,
playing games, taking surveys and watching video, then withdraw it. This
repository is the **admin panel** that manages all of that and the **JSON API**
the CashLoop mobile app consumes.

---

## Tech stack

| Layer | Technology |
|---|---|
| Framework | Laravel 13 · PHP 8.3+ |
| Admin panel | Blade + Livewire 3 (Jetstream 5 / Fortify) |
| Public site | Inertia.js 3 + React 19 |
| Mobile auth | Firebase ID token → JWT (`tymon/jwt-auth`) + refresh tokens |
| Database | MySQL 8 / MariaDB 10.3+ |
| Build | Vite 5 · Tailwind 3 |

Requires PHP extensions: `bcmath` (money arithmetic — not optional), `ctype`,
`curl`, `dom`, `fileinfo`, `gd`, `mbstring`, `openssl`, `pdo_mysql`,
`tokenizer`, `xml`, `zip`.

---

## Getting started

```bash
git clone <repo-url> && cd cashloop-admin-panel

composer install
npm install

cp .env.example .env
php artisan key:generate
php artisan jwt:secret
```

Set `DB_*` and `ADMIN_EMAIL` / `ADMIN_PASSWORD` in `.env`, then:

```bash
php artisan migrate:fresh --seed   # builds the whole schema and seeds core data
php artisan storage:link           # offer icons, logos, banners

php artisan db:seed --class=DemoSeeder   # optional: demo users, offers, history

npm run dev                        # or `npm run build` — see below
php artisan serve                  # http://127.0.0.1:8000
```

Sign in at `/en/web-login` with the admin credentials from `.env`.

> **`ADMIN_PASSWORD` has no default and is not optional.** `AdminUserSeeder`
> skips creating the admin — with a warning — if it is blank, is a common
> default (`password`, `admin`, `changeme`, …), or is under 12 characters. That
> applies in **every** environment, not just production: a local seed gets
> deployed, and this is the account that approves withdrawals.
>
> If seeding finishes but you cannot sign in, this is why. Read the output.

> **The front-end assets must be built.** The panel renders through Vite, so a
> checkout with no `public/build/` throws *"Unable to locate file in Vite
> manifest"* and **every page returns 500** — not a missing stylesheet. Run
> `npm run build` (or `npm run dev` while developing). Re-run it after changing
> anything under `resources/css/` or `resources/js/`.

**No SQL dump is needed, and none should ever be imported.** The migrations are
the schema.

---

## How it fits together

```
Offer / offerwall  →  click (uuid)  →  partner  →  postback  →  conversion
                                                                    ↓
                                                        wallet_transactions
                                                                    ↓
                                                    quests · levels · referrals
                                                                    ↓
                                                     cashout request → payout
```

**The click id is the spine.** When a user opens an offer we mint a uuid, send
it to the partner as their sub-id, and their postback returns it. That is how a
conversion finds its user. A raw user id is never sent to a third party.

**Every balance change is a ledger row.** `WalletService` is the only thing that
may touch `wallets`; it locks the row and writes a matching
`wallet_transactions` entry in the same transaction. `wallet:verify` re-sums the
ledger hourly and exits non-zero on drift.

**Adding a partner is data, not code.** Credentials live in `provider_settings`,
dialect lives in an adapter class, and a partner with neither falls back to a
config-driven generic adapter.

**Postbacks fail closed.** A postback is an anonymous HTTP request whose payload
says "credit this user", so every adapter refuses one unless that provider has
**either** a secret it can verify **or** an IP allowlist. A provider configured
with neither is rejected with `no_secret_and_no_ip_allowlist` — it credits
nobody rather than crediting them unverified. BitLabs and Torox are stricter
still: both sign every callback, so an allowlist alone is not accepted for them.

Partner credentials can be seeded from `.env` on first run — see
`provider_credentials` in `config/cashloop.php` and the `CPALEAD_*`,
`BITLABS_*` and `TOROX_*` keys in `.env.example`. Left blank, the seeder writes
nothing, which is the correct behaviour for a production seed. After the first
run the panel is the authority and re-seeding leaves existing settings alone.

---

## Ground rules

These are not style preferences — each one exists because breaking it caused a
real problem.

1. **Every schema change is a migration.** No manual SQL, ever. The whole point
   of this rebuild was that the previous project could not be recreated from
   its own code.
2. **Money is `decimal(14,6)` handled by `App\Support\Money` (bcmath).** Never
   floats: `0.1 + 0.2 !== 0.3`, and a cent lost per conversion is real money.
3. **Never write a balance outside `WalletService`.** If you find yourself
   reaching for `$wallet->update(...)`, the answer is a ledger entry.
4. **`env()` only in `config/*.php`.** It returns null once the config is
   cached. `ConfigurationSafetyTest` fails the build if this is broken.
5. **Ledger and audit rows are immutable.** Correct a mistake with a
   compensating entry; the models throw on update and delete.
6. **The mobile API contract is frozen.** Additive fields are fine; breaking
   changes go to `/api/v2`.
7. **Anything that moves money between accounts must prove ownership.**
   `POST /auth/relink` merges an abandoned guest into a signed-in account and
   takes the *guest's own refresh token* as authorisation — a bare user id would
   let any caller name someone else's account and sweep its balance.
8. **A column you intend to write must be in `$fillable`.** Mass assignment
   drops what is not listed, silently: the model saves, the panel reports
   success, and the value never changed.

---

## Commands

```bash
composer test            # php artisan test
composer refresh         # migrate:fresh --seed
composer refresh-demo    # …plus demo fixtures
composer lint            # pint
composer check           # lint --test + tests

php artisan wallet:verify        # ledger integrity; --fix rewrites from the ledger
php artisan offers:expire        # retire ended / capped / stale offers
php artisan logs:prune           # trim postback logs and unconverted clicks
```

The scheduler needs one cron entry:

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

---

## Testing

```bash
php artisan test
```

**256 tests across 30 feature files, all passing.** They cover the wallet ledger
and its drift check, postback ingestion and per-partner signature verification,
cashout eligibility and holds, the guest merge and what it must refuse,
referrals, quests and streaks, geo targeting, admin authorisation, and a full
`migrate:fresh --seed` install.

`phpunit.xml` pins the suite to in-memory SQLite. **Do not remove those two
lines** — the Jetstream tests use `RefreshDatabase`, which runs `migrate:fresh`
against whatever database `.env` points at.

`phpunit.xml` also sets `ADMIN_PASSWORD` and blanks every partner credential.
Both matter: the seeder refuses a weak admin password in all environments, and
the suite asserts that a production seed writes no secrets, so a developer's
own `.env` values must not leak in.

One honest caveat: SQLite compiles `SELECT ... FOR UPDATE` to a no-op, so the
row locking that protects wallet balances is *not* exercised locally. CI runs
the suite against MySQL 8 as well, which is where that guarantee is verified.
