# PayNika - Italian School Subscription Commerce Platform

This is the revised technical implementation plan. It focuses on a robust subscription commerce domain model, explicit separation of concerns, and concrete integration paths with WayForPay, while remaining fully compliant with traditional shared hosting constraints (PHP 8.2+, MySQL, Cron, no Node.js runtime).

## 1. Corrected Final Architecture

The architecture maintains a lightweight, modern PHP approach without "enterprise" over-engineering, using a front controller and specific layers for business rules.

- **Front Controller:** `public/index.php` routes all traffic.
- **Router:** A minimal, custom `app/Router.php` to handle dynamic URL patterns and middleware matching.
- **Controllers:** Map HTTP requests to Services. Kept very thin (`app/Controllers`).
- **Services:** Execute core business logic and orchestrate domain entities (`app/Services`).
  - `SubscriptionService`: Manages business rules (7-day cancellation logic, state changes).
  - `CheckoutService`: Coordinates orders, prices, and WFP payload building.
  - `WebhookService`: Verifies provider events and dispatches state updates.
- **Repositories:** Abstract database queries using secure PDO (`app/Repositories`).
- **Provider Adapters:** `app/Providers/WayForPayProvider.php` is strictly responsible for API communication, HMAC signature generation, and parsing payloads based purely on WFP documentation. No domain business logic lives here.
- **Views:** Server-rendered PHP templates located in `resources/views/`, utilizing Alpine.js for lightweight frontend interactions (modals, tabs) without an SPA build toolchain.
- **Environment:** Config read from `.env` and parsed into an immutable `config/` array.

### Directory Structure Summary
```text
PAY_NIKA/
├── app/
│   ├── Controllers/
│   ├── Services/ 
│   ├── Repositories/
│   ├── Models/         # Entity definitions
│   ├── Middleware/
│   └── Providers/      # WayForPay, Mailer
├── bootstrap/
├── config/
├── database/
│   └── migrations/
├── public/           
├── resources/
│   └── views/          # Admin, Client, Email templates
├── cron/               # Reminders and state sweepers
└── .env
```

---

## 2. Corrected Complete Database Schema

The schema abstracts pricing from products and rigorously models subscriptions, changes, and financial records.

```sql
-- USERS & ROLES
CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    role VARCHAR(50) DEFAULT 'guest', -- (e.g., guest, student_basic, admin)
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

CREATE TABLE roles (
    -- Optional explicit roles table if dynamic roles are needed, otherwise an ENUM/string on users is sufficient. Let's use a config-based role, so string is fine for now, or a simple reference.
    id INT AUTO_INCREMENT PRIMARY KEY,
    code VARCHAR(50) UNIQUE NOT NULL,
    name VARCHAR(100) NOT NULL
);

-- CATALOG: PRODUCTS
CREATE TABLE products (
    id INT AUTO_INCREMENT PRIMARY KEY,
    type VARCHAR(50) NOT NULL, -- (subscription, one_time, addon)
    slug VARCHAR(100) UNIQUE NOT NULL,
    title VARCHAR(150) NOT NULL,
    short_description TEXT,
    long_description TEXT,
    image_path VARCHAR(255) NULL,
    is_active BOOLEAN DEFAULT TRUE,
    sort_order INT DEFAULT 0,
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

-- CATALOG: PRICES
CREATE TABLE prices (
    id INT AUTO_INCREMENT PRIMARY KEY,
    product_id INT NOT NULL,
    amount DECIMAL(10, 2) NOT NULL,
    currency VARCHAR(3) DEFAULT 'EUR',
    billing_type VARCHAR(50) NOT NULL, -- (one_time, recurring)
    interval_unit VARCHAR(20) NULL,    -- (week, month)
    interval_count INT NULL,
    is_active BOOLEAN DEFAULT TRUE,
    FOREIGN KEY (product_id) REFERENCES products(id)
);

-- CATALOG: PLANS
-- Associates pricing/product specifically with subscription features
CREATE TABLE plans (
    id INT AUTO_INCREMENT PRIMARY KEY,
    product_id INT NOT NULL,
    internal_code VARCHAR(100) UNIQUE NOT NULL,
    display_name VARCHAR(150) NOT NULL,
    trial_days INT NULL,
    is_active BOOLEAN DEFAULT TRUE,
    visibility_mode VARCHAR(50) DEFAULT 'public',
    notes TEXT,
    FOREIGN KEY (product_id) REFERENCES products(id)
);

CREATE TABLE product_role_visibility (
    product_id INT NOT NULL,
    role_code VARCHAR(50) NOT NULL,
    PRIMARY KEY (product_id, role_code),
    FOREIGN KEY (product_id) REFERENCES products(id)
);

CREATE TABLE upsells (
    id INT AUTO_INCREMENT PRIMARY KEY,
    parent_product_id INT NOT NULL,
    upsell_product_id INT NOT NULL,
    rules_json JSON NULL,
    FOREIGN KEY (parent_product_id) REFERENCES products(id),
    FOREIGN KEY (upsell_product_id) REFERENCES products(id)
);

-- COMMERCE: ORDERS (Orchestration)
CREATE TABLE orders (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    order_type VARCHAR(50) NOT NULL, -- (subscription_checkout, upsell, addon, manual_admin)
    total_amount DECIMAL(10, 2) NOT NULL,
    currency VARCHAR(3) DEFAULT 'EUR',
    status VARCHAR(50) DEFAULT 'created', -- (created, pending, paid, failed)
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE TABLE order_items (
    id INT AUTO_INCREMENT PRIMARY KEY,
    order_id INT NOT NULL,
    product_id INT NOT NULL,
    price_id INT NOT NULL,
    quantity INT DEFAULT 1,
    unit_price DECIMAL(10, 2) NOT NULL,
    FOREIGN KEY (order_id) REFERENCES orders(id),
    FOREIGN KEY (product_id) REFERENCES products(id)
);

-- COMMERCE: SUBSCRIPTIONS & CANCELLATION
CREATE TABLE subscriptions (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    plan_id INT NOT NULL,
    status VARCHAR(50) NOT NULL, -- (pending, active, past_due, pending_cancel, cancel_scheduled, cancelled, expired)
    provider_recurring_id VARCHAR(255) NULL, -- WFP Recurrent ID
    started_at TIMESTAMP NULL,
    next_charge_at TIMESTAMP NULL,
    
    -- Cancellation Policy Explicit Modeling
    cancel_requested_at TIMESTAMP NULL,
    cancellation_policy_days_snapshot INT DEFAULT 7,
    effective_cancellation_at TIMESTAMP NULL,
    cancellation_mode VARCHAR(50) NULL, -- (immediate_cycle, next_cycle)
    provider_cancel_status VARCHAR(50) NULL,
    internal_status VARCHAR(50) NULL,
    
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (plan_id) REFERENCES plans(id)
);

CREATE TABLE subscription_change_requests (
    id INT AUTO_INCREMENT PRIMARY KEY,
    subscription_id INT NOT NULL,
    request_type VARCHAR(50) NOT NULL, -- (cancel, upgrade, downgrade)
    requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    processed_at TIMESTAMP NULL,
    status VARCHAR(50) DEFAULT 'pending',
    notes TEXT,
    FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
);

-- FINANCES: PAYMENTS & EVENTS
CREATE TABLE payments (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    order_id INT NULL,
    subscription_id INT NULL,
    provider VARCHAR(50) DEFAULT 'wayforpay',
    order_reference VARCHAR(255) UNIQUE NOT NULL, -- Our internal unique order string sent to WFP
    merchant_account VARCHAR(150) NOT NULL,
    provider_transaction_id VARCHAR(255) NULL,
    provider_recurring_id VARCHAR(255) NULL,
    status VARCHAR(50) NOT NULL, -- (created, pending, approved, failed, refunded)
    amount DECIMAL(10, 2) NOT NULL,
    currency VARCHAR(3) DEFAULT 'EUR',
    raw_payload_json JSON NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    paid_at TIMESTAMP NULL,
    failed_at TIMESTAMP NULL,
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (order_id) REFERENCES orders(id),
    FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
);

CREATE TABLE provider_events (
    id INT AUTO_INCREMENT PRIMARY KEY,
    provider VARCHAR(50) NOT NULL,
    event_type VARCHAR(100) NOT NULL,
    event_reference VARCHAR(255) NULL,
    payload_json JSON NOT NULL,
    signature_valid BOOLEAN NOT NULL,
    processed_at TIMESTAMP NULL,
    processing_status VARCHAR(50) NOT NULL, -- (unprocessed, processed, failed, ignored)
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- SYSTEM: LOGS & REMINDERS
CREATE TABLE reminders (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    subscription_id INT NOT NULL,
    reminder_type VARCHAR(50) NOT NULL, -- (upcoming_renewal_7d)
    scheduled_for TIMESTAMP NOT NULL,
    sent_at TIMESTAMP NULL,
    status VARCHAR(50) DEFAULT 'pending',
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
);

CREATE TABLE audit_logs (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NULL, -- System or Admin
    action VARCHAR(150) NOT NULL,
    entity_type VARCHAR(50) NOT NULL,
    entity_id INT NOT NULL,
    changes_json JSON NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```

---

## 3. WayForPay Integration Flows

The Provider is fully isolated. We use WFP precisely as documented, relying on WFP as the billing engine and PayNika as the orchestrator.

### A. Subscription Purchase (Hosted Checkout)
1. User clicks "Subscribe".
2. `CheckoutService` creates an `Order` (`subscription_checkout`) and an initial `Payment` (`created` status).
3. We generate a unique `orderReference` (e.g., `SUB-UID-TIMESTAMP`).
4. We generate the HMAC MD5 signature based on the required string format (`merchantAccount;merchantDomainName;orderReference...`).
5. We submit a POST form (or redirect) to `https://secure.wayforpay.com/pay` with `regularMode=1`, `regularBehavior=preset`, and the `orderReference`.
6. WFP securely processes the card and establishing recurrence.

### B. Recurring Renewal Sync (Webhook / Callback Verification)
1. WFP attempts renewal and sends an asynchronous S2S POST callback (Webhook) to `https://pay.nikaitaliano.com/api/webhooks/wayforpay`.
2. Our `WebhookService` intercepts it, logging the raw payload immediately into `provider_events`.
3. `WayForPayProvider` verifies the HMAC signature on the incoming payload.
4. If `transactionStatus` = `Approved`:
   - System updates `payments` (creates new mapped payment entry or updates existing pending).
   - System updates `subscriptions.next_charge_at` based on the payload or interval calculation.
   - Updates `subscriptions.status` to `active`.
5. If payment fails, we log it, set status to `past_due`, and potentially restrict user access via roles.
6. The webhook returns the required JSON response confirming receipt (`{"orderReference":"...", "status":"accept", "time":... "signature":"..."}`).

### C. Cancellation Policy & Action
1. User requests cancellation via User Dashboard.
2. `SubscriptionService` compares `now()` to `subscriptions.next_charge_at`.
   - **Scenario 1 (>= 7 days):**
     - Status becomes `cancel_scheduled`.
     - `cancellation_mode` = `immediate_cycle`.
     - System calls the WFP API (`https://api.wayforpay.com/api` with action `REMOVE_CHARGE`) passing `merchantAccount`, `orderReference` (the original one) to kill the token immediately.
     - `provider_cancel_status` becomes `cancelled`.
   - **Scenario 2 (< 7 days):**
     - Status becomes `pending_cancel`.
     - `cancellation_mode` = `next_cycle`.
     - We *do not* kill the WFP token immediately. 
     - A cronjob running daily checks for subscriptions in `pending_cancel` that have passed their `next_charge_at` line, at which point it calls the WFP API to terminate the recurrence, moving status to `cancel_scheduled` / `cancelled`.
3. An audit log and `subscription_change_requests` row are recorded.

### D. One-Time Upsell (Hosted Checkout)
1. Triggered post-purchase or via user dashboard.
2. Creates an `Order` (`upsell` type) mapped to the one-time `product_id`.
3. Redirection to WFP hosted checkout WITHOUT the `regularMode` parameters.
4. Webhook confirms payment; System provisions the product/addon to the user immediately.

---

## 4. Implementation Roadmap by Phases

### Phase 1: Foundation & Catalog
- Scaffold directory structure, custom router, base config and PDO wrapper.
- Create migration script (`01_schema.sql`).
- Build domain entities/models (`Product`, `Price`, `Plan`, `User`).
- Build Admin interfaces to manage Products, Prices, Plans, and Visibility rules.

### Phase 2: Checkout & Orchestration Data
- Implement generic Order and Payment models.
- Implement User Authentication (Login / Register).
- Build the WayForPay Provider Adapter (Signature hashing, checkout form generation).
- Create the Client Area logic to view available Plans (checking `product_role_visibility`).

### Phase 3: Webhooks & The Billing Engine Synchronization
- Build the Webhook Controller and `WebhookService`.
- Implement `provider_events` logging for robust traceability.
- Connect valid webhooks to `Subscription` state transitions (`created` -> `active`, `payment failed` -> `past_due`).

### Phase 4: Business Rules (Cancellations & Reminders)
- Implement `SubscriptionService->requestCancellation()` mapping logic for the < 7 days / >= 7 days business rule.
- Create Cron script (`cron/daily_tasks.php`):
  - Send reminder emails using PHPMailer for users approaching their `next_charge_at` (7 days out).
  - Sweep `pending_cancel` subscriptions past renewal and fire WFP API tokens deletions.
- Build User Dashboard UI to display exact status, next billing, and cancellation options.

### Phase 5: Upsells & Admin Orchestration
- Build Upsell checkout flows (one-time purchase items).
- Complete Admin dashboards to view specific subscriptions, override states, read the `provider_events` logs, and manage customer account profiles.
- Final Styling tweaks with clean, server-side CSS mapping.

---
**Configuration Environment Values:**
```dotenv
APP_URL=https://pay.nikaitaliano.com
DB_HOST=127.0.0.1
DB_NAME=paynika
DB_USER=root
DB_PASS=
WFP_MERCHANT_ACCOUNT=tveritina_net
WFP_SECRET_KEY=...
WFP_DOMAIN_NAME=pay.nikaitaliano.com
WFP_RETURN_URL=https://pay.nikaitaliano.com/checkout/return
WFP_WEBHOOK_URL=https://pay.nikaitaliano.com/api/webhooks/wfp
WFP_TEST_MODE=true
MAIL_HOST=...
MAIL_USER=...
MAIL_PASS=...
CRON_SECRET=...
```
