# Payment Discounts And Cart Payments

Read this document before inspecting payment or cart code. It is the implementation map for payment pricing, automatic providers, manual payment URLs, order persistence, purchase history, and the pending-payment cart section.

## Fast File Map

| Concern | File | Important API or behavior |
|---|---|---|
| Configuration | `src/settings/config.example.php`, `src/settings/config.developer.php` | `PAYMENT_MODULES` entries and `discount` percentages |
| Price and method helpers | `src/core/modules/PaymentModule/PaymentHelpers.php` | `CalculatePriceForPaymentType`, `ShowPaymentMethods`, availability, manual methods, start flow |
| Shared provider behavior | `src/core/modules/PaymentModule/PaymentModule.php` | `createRequest`, `checkPaymentStatus`, `completePayment`, audit and fulfillment coordination |
| PayPal adapters | `src/core/modules/paypal/paypal.class.php`, `paypal.php`, `src/core/modules/paypalv2/` | Legacy PayPal IPN and PayPal Orders v2 checkout/webhook/manual reconciliation |
| PayRam adapter | `src/core/modules/payram/payram.class.php`, `payram.php` | Hosted checkout, status validation, and webhook processing |
| Order persistence | `src/model/base/order.model.php` | Order creation, pending expiry, `markPaid`, fulfillment, history |
| Cart controller | `src/pages/private/cart/cart.controller.php` | Cart mutations, checkout, pending-order lookup, AJAX boundary |
| Cart partial | `src/pages/private/cart/cart-items.view.php` | Only the current cart and its AJAX-replaced HTML |
| Full cart view | `src/pages/private/cart/cart.view.php` | Current cart plus the full-page pending-payment section |
| Purchase history | `src/pages/private/purchase-history/purchase-history.view.php` | Paid amount, discount row, retry payment options |
| Schema | `installation/cms.sql` | CMS V8/V9 order and payment transaction tables |
| Payment audit lookup | `src/model/base/store.model.php` | `StoreModel::getTransactionByTrxIDByTrxID()` reads the matching `cms_payments` row; `net_money` is the true received amount |
| Payment tests | `tests/auto/integration-web/modules/PaymentModule/` | Calculation, provider, and availability coverage |

For generic payment-module API details, see [Payment Module API](../api/modules/payment/payment.module.md). For PayPal v2 implementation details, see [PayPal v2 Module API](../api/modules/payment/paypalv2.module.md). For legacy PayPal-specific details, see [PayPal Module API](../api/modules/payment/paypal.module.md). For PayRam-specific details, see [PayRam Module API](../api/modules/payment/payram.module.md).

## Configuration

Every customer-visible entry in `PAYMENT_MODULES` may define:

```php
'paypal' => [
  'enabled' => true,
  'payment_module' => 'paypal',
  'label' => 'PayPal',
  'discount' => 10,
  'additional_fee' => 0,
  // Provider-specific fields...
],
```

`discount` is a numeric percentage without the percent sign:

- Positive values reduce the price. `10` means a 10% discount.
- Negative values increase the price. `-10` means a 10% fee/recharge.
- Missing values are treated as `0` by the calculation helper.
- Keep the key in both config templates when adding or changing payment configuration.

`additional_fee` is an optional percentage applied only to the recorded `net_money` after the provider's existing fees have been calculated. It is not charged to the customer. Use `0` or omit it to disable it, or set an approximation of deductions such as crypto transfer costs to make the recorded net funds more realistic.

Payment entries are classified by implementation:

- Automatic provider: `enabled`, a non-null `payment_module`, and a matching `src/core/modules/<payment_module>/<payment_module>.php` file.
- Manual payment: `enabled` and `payment_module = null`; no provider module file is required.
- Disabled or incomplete entries are not available to customers.

Every `PAYMENT_MODULES` entry must use a non-empty string key. That key is the persisted payment identifier used for orders, temporary provider transactions, pricing, and callback URLs. `label` is display-only. `payment_module` identifies the provider implementation and is used only to load its module; multiple entries may use the same provider module.

PayRam uses `api_key` and optionally `base_url` from its payment configuration. When `base_url` is omitted, it defaults to `https://payramtest.conquer.es` in development and `https://payram.conquer.es/` in production. Accepted paid statuses are fixed by the PayRam module as `FILLED` and `OVER_FILLED`. `prod_mode` also controls provider logging behavior. `order_prefix` is prepended to locally stored references so multiple web instances can share one PayRam API.

Methods with `prod_mode = false` are visible and usable only by accounts with `State >= GM_STATE`. This applies to customer method listing and checkout creation. Webhook entrypoints and status refresh loading remain available. Manual URL methods without `prod_mode` remain available.

`enabled=false` will show anyways the method in the admin manual-sale selector.

## Price Contract

`PaymentHelpers::CalculatePriceForPaymentType($amount, $paymentMethodAsString)` is the only shared price calculation contract.

It returns:

```php
[
  'basePrice' => original amount rounded to two decimals,
  'newPrice' => round(basePrice * (1 - percentage / 100), 2),
  'percentage' => configured number without `%`,
]
```

Examples:

- `100` with `10` becomes `90`.
- `100` with `-10` becomes `110`.
- `100` with `0` remains `100`.

The initiator calculates the final price before redirecting to an automatic provider. The callback calculates it again from the stored original `cms_orders.total`; never trust a price supplied by the browser or provider request.

For post-payment email or audit details, use the persisted order row and matching `cms_payments` row rather than recalculating from the order total. The order stores the configured payment method and transaction ID; `cms_payments.net_money` stores the provider net amount after provider fees and the configured `additional_fee` estimate.

## Payment Method Rendering

`PaymentHelpers::ShowPaymentMethods($amount, $currency, $orderId = null)` returns HTML. It does not echo the HTML.

- With no `$orderId`, forms use `cart_action=checkout`.
- With an `$orderId`, forms use `cart_action=pay-order` and include that order ID.
- It uses `getAvailableMethods()` and calculates each method independently.
- It renders module-backed methods first under `Automatic Approval`, followed by manual methods under `Manual Approval`.
- It displays the method image and label.
- Positive percentages display as `Discount: -N%`.
- Negative percentages display as `Fee: +N%`.
- The final `To pay` price is always displayed.

Current call sites:

- `src/pages/private/cart/cart-items.view.php` renders payment options for the current cart.
- `src/pages/private/cart/cart.view.php` renders payment options for the pending order.
- `src/pages/private/purchase-history/purchase-history.view.php` renders retry options for a payable order.

The class name is `PaymentHelpers` singular. Keep the requested method names `CalculatePriceForPaymentType` and `ShowPaymentMethods` unchanged because they are part of the current application API.

## Cart Request Boundary

The cart has two rendering paths. Do not merge them:

1. A full cart request runs `cart.controller.php`, calculates `$cartItems`, performs one pending-order lookup for non-AJAX requests, and includes `cart.view.php`.
2. An AJAX cart mutation calls `cartRespond()`, optionally renders `cart-items.view.php` into `cartHtml`, returns JSON, and exits before the controller's final full-page variables are used.

The pending-order lookup must not be inside `cartRender()` or `cart-items.view.php`.

`cart.view.php` renders the pending section outside `#cart-content`. `src/pages/private/cart/cart.js` replaces only `#cart-content`, so updating quantity, adding, removing, or clearing a cart item must not re-render or query the pending order.

The current controller enforces this with:

```php
$pendingOrder = cartIsAjaxRequest() ? null : cartGetPendingOrder();
```

Do not add pending-history queries to the AJAX fragment. The current cart product query is expected; the pending-order history query is not.

## Pending Order Selection And Display

`cartGetPendingOrder()` walks `OrderModel::getHistory(ACCOUNT_DATA)`. History is sorted newest-first, so the first `PENDING` order is the newest unpaid transaction. This naturally skips a newer `PAID` order and selects the next older pending order.

`OrderModel::getHistory()` calls `expirePending()` before selecting rows. Pending orders expire after 24 hours. Provider callbacks have a 25-hour database grace window through `isPayable($order, true)` and `markPaid()`.

The pending section in `cart.view.php` must contain:

- A `Pending payment` heading.
- A warning notification explaining the 24-hour cancellation window.
- A success notification telling a customer who already paid to refresh after one minute.
- A `cart-table` using the order-product snapshots from `cms_orders_products`.
- Article, quantity, line total, and order total columns matching current cart markup.
- `PaymentHelpers::ShowPaymentMethods()` with the pending order total, currency, and order ID.

Pending products are immutable order snapshots. They do not have update/remove controls. Their image field is not stored in `cms_orders_products`; `FormComponents::getProductImageURL()` therefore uses the default image when no image is present.

## Checkout And Manual Payments

Automatic checkout flow:

1. `OrderModel::create()` reloads products from `cms_shop`, calculates the original total, writes a `PENDING` order with the selected `payment_method`, and stores immutable product rows.
2. `PaymentHelpers::startPayment()` calculates `newPrice` and passes it as the third argument to `createRequest()`.
3. The provider charges `newPrice`, while the order's stored `total` remains the original amount.

Admins may include disabled `cms_shop` products in the store and cart. Disabled names are marked `(disabled)` in presentation only. Non-admin carts skip disabled products, including mixed carts, and cannot create an order containing only disabled products. Pack contents retain their current behavior; only the pack product itself is subject to the disabled-product rule.

PayRam sends `newPrice` as `amountInUSD`. Its hosted checkout controls the selected cryptocurrency and network. A PayRam reference is stored in `cms_orders_temp_txn`, allowing retries for the same order and provider to reuse the same reference without interfering with another payment method.

Manual URL flow:

1. `PaymentHelpers::isManualPayment()` detects an available method with `url`.
2. The controller creates the normal pending order and clears the cart.
3. The controller does not load a provider module or new_tab externally.
4. The user returns to the cart with the configured URL and GM-contact instructions.
5. The pending order remains visible in the full cart view.

Manual message flow:

1. Configure an enabled `PAYMENT_MODULES` entry with `payment_module => null` and an optional `parameters` map.
2. The default message key is `payments.manual.<method>`. Set `message` to another translation key to override it.
3. The selected method creates the normal pending order and clears the cart.
4. The controller renders the translated message through the same `$paymentHtml` branch used by PayRam.
5. Translation parameters include `order_id`, the calculated final `amount`, `currency`, `purchase_history_url`, and every configured value from `parameters`.
6. `purchase_history_url` is passed as plain text to the translation replacement system. The translation is responsible for rendering it, normally as an HTML link with the label `You can choose another payment method`.

Example:

```php
'bank_transfer' => [
  'enabled' => true,
  'payment_module' => null,
  'label' => 'Bank transfer',
  'discount' => 0,
  'parameters' => [
    'accountNumber' => '...',
    'phoneNumber' => '...',
  ],
  // Defaults to payments.manual.bank_transfer.
  // 'message' => 'payments.manual.custom_bank_transfer',
],
```

The English translation file must define the default key. Custom parameter names are available as `{accountNumber}`, `{phoneNumber}`, and so on. Runtime parameters use reserved names and override duplicate configuration keys.

Manual payment QR codes are optional. Add `qr_data` to a manual payment configuration to encode a provider payload or URL in an inline SVG QR code. Every custom placeholder is read from the method's flat `receiver_data` map, so `{address}`, `{token_contract}`, `{chain_id}`, `{token_decimals}`, and `{token_mint}` require no core-code changes. Runtime placeholders `{amount}`, `{currency}`, and `{order_id}` are also available and override a receiver-data key with the same name. `qr_logo` may contain a local path relative to `src/` and is embedded in the center of the QR when present. For Zelle, configure the tokenized URL manually, for example `https://enroll.zellepay.com/qr-codes?data=YOUR_TOKEN`; the URL is encoded as QR content, not loaded as an image. Static recipient QRs do not automatically confirm payments or guarantee amount prefilling.

The cryptocurrency configuration includes BIP-21 Bitcoin, ERC-681 Ethereum ERC-20, and Solana Pay SPL-token examples. The Bitcoin example omits the amount because the store total is normally USD. Ethereum and Solana amount placeholders should only be used when the configured token denomination matches the store amount, such as a USD stablecoin; the application does not perform exchange-rate or decimal conversion.

Do not treat a manual URL as an automatic provider. `startPayment()` is for module-backed providers; the controller handles manual methods before calling it.

## Callback And Persistence

Provider adapters should perform provider-specific validation, map the response into payment details, and call the protected `PaymentModule::completePayment()` method for a successful provider status.

`completePayment()` performs the shared work:

1. Recalculate the price from the original order total and `getPaymentName()`.
2. Reject an amount lower than the calculated `newPrice`.
3. Set the audit `order_id` and payment method.
4. Call `logPurchase()` so `cms_payments` stores provider-reported gross amounts and net money after provider fees plus `additional_fee`.
5. Call `OrderModel::markPaid()` with payment method, transaction ID, `newPrice`, and percentage.
6. Use the conditional update result for the atomic race check.
7. Fulfill the order only for the first payment or a matching duplicate callback.
8. Send the paid email only when this callback changed the order to `PAID`.

PayRam callbacks additionally prepend `order_prefix`, re-query the provider by the raw reference ID, and require the prefixed reference to exist in `cms_orders_temp_txn` before processing. Accepted statuses default to `FILLED` and `OVER_FILLED`. `PARTIALLY_FILLED` is approved when its received amount is within the configured `partial_payment_tolerance` (default `1.5%`) of the calculated PayRam amount; PayRam maps the expected amount to `mc_gross` for the shared completion check while retaining the actual received amount in `net_money`. Other partial, open, and cancelled statuses are logged and ignored. Its webhook signature is validated against the raw body using the configured API key. On-chain details are stored in `cms_orders_crypto_data`. Production mode suppresses console output only; provider file logs remain enabled.

When a customer submits the PayRam payment method again, the module performs the same authenticated status check synchronously before rendering the hosted checkout. A session timestamp limits these manual checks to one request every 10 seconds.

`OrderModel::markPaid()` writes:

- `status = PAID`
- `payment_method`
- `txn_id`
- `total_after_discount`
- `discount_applied`
- payment and update timestamps

`cms_payments` must not receive an original-price column. Its `mc_gross` describes the provider transaction, while `net_money` describes the provider net amount after the configured `additional_fee` adjustment.

## Database And Migration

The V8 section of `installation/cms.sql` adds:

- `cms_orders.total_after_discount decimal(12,2)`
- `cms_orders.discount_applied decimal(6,2)`

The V9 section adds `cms_orders_temp_txn` for temporary provider references and `cms_orders_crypto_data` for on-chain transaction details.

It backfills existing rows with `total_after_discount = total` and `discount_applied = 0`. `createManual()` also writes the original manual price into `total_after_discount` with a zero percentage.

The complete `installation/cms.sql` contains older installation and migration sections and is not a safe production migration runner. For an existing installation, apply the relevant V8 and V9 statements manually after a database backup.

## History Display

`purchase-history.view.php` reads orders, not `cms_payments`:

- Paid orders display original `total` struck through and `total_after_discount` as the amount paid.
- A `Discount` row is displayed below the total only when `discount_applied` is non-zero.
- Pending orders show retry payment methods using the original order total as the calculation base.
- Existing paid rows depend on the V8 backfill; do not add a runtime fallback that hides a missing migration.

## Safe Change Checklist

Before changing payment or cart pricing:

1. Read this document and the narrower API document for the provider being changed.
2. Inspect `git status` and preserve unrelated staged or working-tree changes.
3. Update both payment configuration templates when changing config shape.
4. Keep all price calculations in `CalculatePriceForPaymentType()`.
5. Keep provider-independent callback work in `PaymentModule::completePayment()`.
6. Keep `cms_payments` as provider audit data; do not add original-price fields there.
7. Keep pending-order lookup out of `cartRender()` and `cart-items.view.php`.
8. Update the current SQL migration section for schema changes.
9. Update this document and the relevant API document when behavior changes.
10. Run the narrowest relevant tests and `make pretty` unless the user explicitly asks to skip them.

## Security Review Boundaries

Use these rules when reviewing payment/cart changes:

- `src/settings/config.developer.php` and `src/settings/config.example.php` are development templates, not production settings. Use them only to understand configuration shape; ignore their values, credentials, URLs, modes and tolerances unless the review explicitly targets development configuration.
- The cart token is a duplicate/replayed-submit guard for `checkout` and `pay-order`, not an authentication, authorization or CSRF security boundary. Do not report token entropy/rotation or missing token checks on add, update, remove and clear, and do not add those checks.
- Authentication comes from private routing/session loading. Order access must still use account-scoped lookups such as `OrderModel::getForAccount()`.
- Payment logs are stored in a private folder outside HTTP access. Do not report their content or filesystem mode as browser/user exposure; review host access or retention only when explicitly in scope.
- Separate application findings from optional development/deployment examples. A development default is not evidence of production exposure.

For payment code, focus the security review on runtime trust boundaries:

1. Re-query provider state server-side; do not complete from browser or webhook fields alone.
2. Match provider reference and customer/account to the local temporary transaction and order.
3. Compare received and expected amounts in the same currency/unit. A crypto-denominated `filledAmount` is not a USD amount unless the provider contract explicitly guarantees it.
4. Validate partial payments against the actual provider-reported received amount before mapping values into the shared completion contract.
5. Keep completion and fulfillment idempotent under concurrent webhook and authenticated status-refresh requests. The CMS order and game fulfillment use separate database connections, so an atomic order update alone does not make reward insertion idempotent.
6. Validate provider-returned checkout URLs before rendering, embedding or opening them when the provider response controls the URL.

## Focused Verification

```bash
composer run test-web-modules
composer run test-model-cms
composer run test-web-flows
make pretty
```

Relevant test files:

- `tests/auto/integration-web/modules/PaymentModule/PaymentHelpersTest.php`
- `tests/auto/integration-web/modules/PaymentModule/PaypalPaymentTest.php`
- `tests/auto/integration-web/models/cms/orderModelTest.php`
- `tests/auto/integration-web/pages/CartFlowTest.php`
- `tests/auto/integration-web/pages/PurchaseHistoryFlowTest.php`
