docs: add comprehensive audit report and architectural recommendation

Checkpoint before implementation. Includes audit findings (FINDINGS.md),
architectural recommendation (RECOMMENDATION.md), and existing code changes
to Form, Order, Render, and form-action.js from recent development.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-04-17 17:00:47 +07:00
parent 0446eb1064
commit 35569923a5
6 changed files with 1014 additions and 153 deletions

196
FINDINGS.md Normal file
View File

@@ -0,0 +1,196 @@
# 🔍 Formipay Plugin — Comprehensive Audit Report
**Date:** April 17, 2026
**Auditor:** GitHub Copilot
**Plugin Version:** 1.0.0
**Files Analyzed:** ~60+ files, ~15,000+ lines of PHP, JS, CSS, HTML
---
## Table of Contents
- [1. Bugs & Defects](#1-bugs--defects)
- [1.1 Critical Bugs](#11-critical-bugs)
- [1.2 Moderate Bugs](#12-moderate-bugs)
- [2. Security Concerns](#2-security-concerns)
- [3. Architecture & Code Quality Issues](#3-architecture--code-quality-issues)
- [4. Missing Features & Modules](#4-missing-features--modules)
- [5. Performance Issues](#5-performance-issues)
- [6. Missing Admin Pages / Settings](#6-missing-admin-pages--settings)
- [7. Code Cleanup Needed](#7-code-cleanup-needed)
- [8. Opportunities & Nice-to-Haves](#8-opportunities--nice-to-haves)
- [9. Summary Priority Matrix](#9-summary-priority-matrix)
- [10. Architectural Recommendation](#10-architectural-recommendation)
---
## 1. Bugs & Defects
### 1.1 Critical Bugs
| # | Location | Issue | Detail |
|---|----------|-------|--------|
| 1 | `includes/Customer.php` ~line 172 `update()` | **Undefined variable — fatal error** | Method builds `$insert_data` and `$where`, but the `$wpdb->update()` call references undefined `$table_name` and `$new_args`. Every customer update will throw a PHP fatal error. |
| 2 | `includes/Order.php` `delete()` | **Undefined `$id` variable** | Uses `$id` in the `$wpdb->delete()` where clause instead of the method parameter `$order_id`. Every order deletion call will fail. |
| 3 | `includes/Order.php` `formipay_bulk_delete_order()` | **Iterates wrong variable** | Loops `foreach($ids as $id)` but calls `$this->delete($order_id)``$order_id` comes from the outer scope (nonce check), not the loop variable. Bulk delete will repeatedly delete the same (or zero) orders. |
| 4 | `includes/Notification/Email.php` `send_email()` | **Wrong class reference — fatal error** | Calls `\Formipay_Notification::update_notification_data()` — this class does not exist. Should use `parent::update_notification_data()`. Email status tracking will crash. |
| 5 | `includes/Integration/Paypal.php` `auto_cancel_order_on_timeout()` | **Undefined `Order` class** | Calls `Order::update(...)` but unlike `BankTransfer.php`, `Paypal.php` does not import `use Formipay\Order as Order`. This will throw a class-not-found error on timeout. |
| 6 | `includes/Integration/Paypal.php` `process_payment()` | **Undefined `self::paypal_settings`** | The PayPal class never declares a `$paypal_settings` property. Accessing it leads to undefined property notices and broken payment flow. |
### 1.2 Moderate Bugs
| # | Location | Issue |
|---|----------|-------|
| 7 | `includes/Payment/BankTransfer.php` `check_unique_code()` | Uses `MAX(id)+1` for unique codes — predictable and subject to race conditions. Two concurrent orders can receive the same unique code. |
| 8 | `includes/Payment/BankTransfer.php` `add_unique_code_details()` | Calls `$this->check_unique_code()` **three times** per request (once for `'item'`, once for `'amount'`, once for `'subtotal'`). Each call queries the DB independently and may return different values. Displayed unique code may not match the stored one. |
| 9 | `admin/functions.php` `formipay_field_type_collection()` | Color field label says `'Number'` instead of `'Color'` — copy-paste error. |
| 10 | `includes/Render.php` field rendering | No `default` fallback rendered when field type doesn't match any case in the switch — unknown field types silently produce no output. |
| 11 | `includes/Order.php` `render_form_submit()` | `$field_value` is sometimes an array (from checkbox fields) but the code assumes string context. Nested `if(is_array($field_value))` only handles one level. |
| 12 | `includes/Thankyou.php` `check_parse_query()` vs `formipay_get_order()` | Old cookie-based URL (`base64_encode`) co-exists with new Token-based validation. `formipay_get_order()` generates the old-format URL for the `thankyou` link, but the new Token system expects a different format. Inconsistent access path. |
---
## 2. Security Concerns
| # | Severity | Location | Issue |
|---|----------|----------|-------|
| 1 | **High** | `includes/Order.php` `retrieve_form_data()` | Cookie `fp_access` uses `maybe_serialize()`**PHP object injection** risk if an attacker can manipulate cookie values. Should use `json_encode()`/`json_decode()`. |
| 2 | **High** | `includes/Thankyou.php` `set_endpoint()` | Calls `flush_rewrite_rules()` on **every `init`** hook — extremely expensive and causes race conditions under concurrent load. Should only flush on activation/deactivation or settings save. |
| 3 | **High** | `includes/Payment/Payment.php` `set_endpoint()` | Same `flush_rewrite_rules()` issue — fires on every page load. |
| 4 | **Medium** | `includes/Order.php` `retrieve_form_data()` | Thank-you URL uses `base64_encode(form_id:::order_id:::session_id)` — base64 is not encryption. Sequential order IDs can be guessed. The `Token` class provides proper tokens but the old path remains active. |
| 5 | **Medium** | `includes/LicenseAPI.php` | All REST endpoints use `permission_callback => '__return_true'`. The `revoke` endpoint has a stub permission callback that always returns `true`. Anyone can revoke or manipulate licenses without authentication. |
| 6 | **Medium** | `includes/Integration/Paypal.php` `webhook_endpoint()` | No PayPal webhook signature verification. An attacker could forge webhook calls to mark orders as paid without actual payment. |
| 7 | **Medium** | `includes/Customer.php` `formipay_tabledata_customers()` | No nonce check (`check_ajax_referer`). Any authenticated user can dump all customer data via direct AJAX call. |
| 8 | **Low** | `includes/Render.php` | Inline `<style>` blocks injected into `<body>` — Content Security Policy (CSP) headers may block these. Should use `wp_add_inline_style()`. |
| 9 | **Low** | `includes/Token.php` `validate()` | Uses MySQL `NOW()` for expiry comparison while token generation uses PHP timezone. Server timezone mismatch could cause valid tokens to be rejected or expired tokens to pass. |
| 10 | **Medium** | `includes/Render.php` (default timezone) | Hardcoded default timezone `'Asia/Jakarta'` in Render.php instead of using `wp_timezone_string()`. |
---
## 3. Architecture & Code Quality Issues
| # | Issue | Detail |
|---|-------|--------|
| 1 | **No Composer dependency management** | Custom `spl_autoload_register` works but there's no `composer.json`. The `vendor/` folder contains manually copied libraries with no version locking, no autoload optimization, and no security update path. |
| 2 | **SingletonTrait incomplete** | `__wakeup()` is `public` (should be `private`). Missing `__sleep()` to prevent serialization-based singleton bypass. |
| 3 | **No database migration system** | `dbDelta()` runs on every `init` for all custom tables. No version tracking for schema changes. Adding columns later requires manual SQL or relying on `dbDelta` diff detection. |
| 4 | **Inconsistent class instantiation** | Some classes use Singleton trait, others (`Token`) don't. `new \Formipay\Token` in `Init.php` bypasses the singleton pattern entirely. |
| 5 | **Global functions vs OOP** | `admin/functions.php` has ~40 global functions (e.g., `formipay_price_format`, `formipay_get_order`, `formipay_currency_array`). Should be in utility/service classes for testability and namespace hygiene. |
| 6 | **Insufficient capability checks** | Several admin-ajax handlers verify nonce but don't check `current_user_can('manage_options')`. Any authenticated user (even subscriber) with a valid nonce could potentially access admin functions. |
| 7 | **Hardcoded English strings** | Many UI strings in JS-localized data and some PHP output are hardcoded in English without `__()` translation functions. |
| 8 | **No proper REST API** | Form submission goes through `admin-ajax.php`. A proper REST API endpoint would be more cache-friendly, better structured, and follow WordPress standards. |
| 9 | **Static version constant** | `FORMIPAY_VERSION` is hardcoded to `'1.0.0'` and never updated programmatically. All cache-busting relies on this value. |
| 10 | **Backup file in production** | `includes/Integration/Paypal.phpbak` exists — should be removed from production codebase. |
| 11 | **No deactivation/uninstall cleanup** | No `uninstall.php` or deactivation hook to clean up custom tables, scheduled events, or options. |
| 12 | **No `.distignore` or build process** | No `.distignore` file for WordPress.org packaging. Development files would be shipped to production. |
---
## 4. Missing Features & Modules
| # | Module / Area | Status | Priority |
|---|---------------|--------|----------|
| 1 | **ExchangeRateAPI** | File exists but is **completely empty** — just an empty class extending Payment | 🔴 High |
| 2 | **License API implementation** | All 4 REST endpoints (`verify`, `activate`, `deactivate`, `revoke`) are **stubs returning hardcoded `ok: true`** | 🔴 High |
| 3 | **Subscription / Recurring payments** | Listed in `readme.txt` as "Planned" but no code exists | 🟡 Medium |
| 4 | **Donation forms** | `formipay_is_donation()` and `donation_config` filter exist, but no donation-specific frontend UI (no "pay what you want" input, no suggested amounts) | 🟡 Medium |
| 5 | **Inventory / Stock management** | Product has `stock_config` in settings but no stock decrement on order, no stock validation before submission, no "out of stock" frontend message | 🟡 Medium |
| 6 | **Product variations on frontend** | Variation config exists in Product admin, but `Order.php` doesn't process variation selections — no variation dropdown rendered on frontend | 🟡 Medium |
| 7 | **Tax system** | No tax calculation anywhere. Products have price, shipping has fees, but there is no tax engine. Essential for physical product sales in most jurisdictions. | 🟡 Medium |
| 8 | **Customer portal / dashboard** | Referenced in readme and `single-formipay.php` template exists, but no customer-facing order history page, no login/registration flow, no "My Orders" view | 🟡 Medium |
| 9 | **Refund system** | `refunded` status exists in status list, but no refund workflow, no partial refund, no payment reversal integration | 🟡 Medium |
| 10 | **Form analytics / reporting** | No form view tracking, no conversion rate calculation, no abandonment tracking, no dashboard charts | 🟢 Low |
| 11 | **Export / Import** | No CSV/Excel export for orders, customers, or products. No bulk import for products. | 🟢 Low |
| 12 | **Outgoing webhook system** | No way to notify external systems (Zapier, Slack, custom endpoints) on order events | 🟢 Low |
| 13 | **Form template library** | No pre-built form templates for common use cases (simple product, donation, registration) | 🟢 Low |
| 14 | **Multi-step form navigation** | Page break config exists in admin, step indicators render, but no frontend JS to actually navigate between steps | 🟡 Medium |
| 15 | **Email log admin page** | `formipay_notification_log` table exists but there's no admin page to view email history, resend failed emails, or debug delivery | 🟡 Medium |
---
## 5. Performance Issues
| # | Location | Issue |
|---|----------|-------|
| 1 | `includes/Thankyou.php` `set_endpoint()` | `flush_rewrite_rules()` on **every page load** — one of the most expensive WordPress operations. Should only run on plugin activation and settings save. |
| 2 | `includes/Payment/Payment.php` `set_endpoint()` | Same `flush_rewrite_rules()` issue. |
| 3 | `includes/Order.php` `formipay_tabledata_orders()` | Runs **two separate full-table queries** — one fetching ALL orders just to count statuses (`O(n)` in memory), plus the paginated query. Should use `COUNT(*) ... GROUP BY status` for the report. |
| 4 | `includes/Customer.php` `formipay_tabledata_customers()` | **No pagination** — loads ALL customers into memory and returns them. Will crash or timeout with large datasets. |
| 5 | `admin/functions.php` `formipay_currency_array()` | `file_get_contents()` of `currencies.json` on **every call** with no caching. Should use a static variable or WordPress transient. |
| 6 | `admin/functions.php` `formipay_country_array()` | Same issue — reads JSON file from disk on every function call. |
| 7 | `includes/Init.php` `default_config()` | The entire ~200-line default config array is loaded on every `plugin_loaded` action. Should be lazy-loaded or only when saving defaults. |
| 8 | `includes/Render.php` | All form CSS is output as inline `<style>` in the HTML `<body>` — not cacheable by browsers, duplicated on every page with the form shortcode. |
| 9 | `includes/Token.php` `create_db()` | Runs `dbDelta()` on every `init` — redundant after first install. |
| 10 | `includes/Customer.php` `create_db()` | Same `dbDelta()` on every `init`. |
| 11 | `includes/Order.php` `create_db()` | Same issue. |
---
## 6. Missing Admin Pages / Settings
| # | Missing Page | Description |
|---|-------------|-------------|
| 1 | **Notification Log** | Table `formipay_notification_log` exists but there's no admin page to view email history, resend failed emails, or debug delivery issues. |
| 2 | **License detail/edit** | License list page exists but there's no detail or edit view (can't manually activate, revoke, or extend a license). |
| 3 | **Dashboard / Analytics** | No overview dashboard with form views, submissions, revenue charts, or conversion rates. |
| 4 | **System Status / Tools** | No page to check PayPal connectivity, email delivery test, database health, or scheduled events. |
| 5 | **Customer order history** | Customer detail page exists but doesn't show linked order history or purchase timeline. |
| 6 | **Import/Export tools** | No admin UI to bulk import products or export order/customer data. |
---
## 7. Code Cleanup Needed
| # | Item | Detail |
|---|------|--------|
| 1 | **Commented-out code** | `Init.php` has commented-out PayPal init, taxonomy, and defer-attribute filter. Should be removed or tracked as TODOs. |
| 2 | **`Paypal.phpbak`** | Backup file in production directory `includes/Integration/Paypal.phpbak` — must be deleted. |
| 3 | **Dead code in `Notification.php`** | `$last_notification_field = count($notification_fields) - 1; $notification_fields[$last_notification_field]['group'] = 'ended'` — uses numeric array index instead of the key name, which breaks if filter order changes. |
| 4 | **Inconsistent nonce naming** | Different pages use different nonce names: `formipay-order-details`, `formipay-admin-coupon-page`, `formipay-admin-licenses`, `formipay-admin-product-page`, `formipay-admin-access-nonce`, `formipay-form-editor`, `formipay-thankyou-nonce`. Should follow a single convention. |
| 5 | **Deprecated `FILTER_SANITIZE_STRING`** | Used in `Customer.php` `customers_page()` — deprecated since PHP 8.1. Should use `FILTER_SANITIZE_SPECIAL_CHARS` or `sanitize_text_field()`. |
| 6 | **Mixed indentation** | Some files use tabs, some spaces, some mix both within the same file. |
| 7 | **Missing PHPDoc blocks** | Most methods have no docblocks. Return types are rarely declared. Makes IDE support and static analysis poor. |
| 8 | **No `.editorconfig`** | No project-wide formatting standard file. |
---
## 8. Opportunities & Nice-to-Haves
| # | Opportunity | Business Impact |
|---|------------|-----------------|
| 1 | **Composer package management** | Replace manual vendor copies with proper Composer dependencies for auto-updates, security patches, and smaller distribution |
| 2 | **React-based form builder** | Replace the partial Vue editor canvas with a full React drag-and-drop form builder for better UX and extensibility |
| 3 | **Webhook/API integrations** | Zapier, Make (Integromat), Slack notifications — extend the notification system for 3rd-party automation |
| 4 | **Stripe payment gateway** | Most requested gateway after PayPal. The abstract `Payment` class makes it architecturally straightforward to add |
| 5 | **PDF invoice generation** | Auto-generate PDF invoices/receipts for orders — high-value feature for business users |
| 6 | **Google Analytics / Facebook Pixel** | E-commerce event tracking (`purchase`, `add_to_cart`) for conversion optimization |
| 7 | **Multi-vendor / marketplace** | Architecture already supports per-form products — extend to multi-vendor marketplace |
| 8 | **Form A/B testing** | Duplicate form feature already exists — add conversion tracking and statistical comparison |
| 9 | **Cart / checkout recovery** | Store partial submissions in `localStorage` + send recovery emails for abandoned forms |
| 10 | **Full localization** | Generate `.pot` file, translate to top 10 languages. Currently only partially translatable |
| 11 | **WP-CLI commands** | `wp formipay order list`, `wp formipay product create`, `wp formipay license verify`, etc. |
| 12 | **Headless / REST API** | Full REST API for headless WordPress + Next.js / Nuxt frontends |
| 13 | **Gutenberg block** | Register a `formipay/form` block for native Gutenberg integration instead of shortcode-only |
| 14 | **Unit & integration tests** | Zero test coverage currently. Critical for a payment plugin handling money |
| 15 | **Rate limiting on public endpoints** | `retrieve_form_data` and `check_coupon_code` have no rate limiting. Vulnerable to brute-force and spam |
---
## 9. Summary Priority Matrix
| Priority | Count | Key Items |
|----------|-------|-----------|
| 🔴 **Critical (fix immediately)** | 6 | Customer update fatal error, Order delete undefined variable, Bulk delete wrong variable, Email send wrong class, flush_rewrite_rules performance, License API stubs |
| 🟡 **High (next sprint)** | 12 | Unique code race condition, PayPal webhook verification, ExchangeRateAPI empty class, Stock management, Donation UI, Notification log page, Customer portal, Tax system |
| 🟢 **Medium (backlog)** | 15 | Refund workflow, Analytics dashboard, Export/Import, Form templates, Gutenber block, Localization, Rate limiting |
| ⚪ **Nice-to-have (roadmap)** | 12 | WP-CLI, A/B testing, Headless API, PDF invoices, Multi-vendor, Cart recovery, CI/CD pipeline |
---
## 10. Architectural Recommendation
> See `RECOMMENDATION.md` for the detailed technical recommendation.
---
*End of audit report.*

469
RECOMMENDATION.md Normal file
View File

@@ -0,0 +1,469 @@
# 🏗️ Formipay — Architectural Recommendation
**Date:** April 17, 2026
**Context:** Based on `FINDINGS.md` audit
---
## The Question
> **Rebuild the plugin with React (admin + frontend), or keep the current SSR shortcode + Vue admin approach and fix all findings?**
---
## TL;DR — The Recommendation
### **Option B: Incremental Modernization (Keep SSR + Vue, Fix & Upgrade)**
**Do NOT do a full rewrite.** Instead, adopt a **phased modernization strategy** that:
1. **Fixes all critical bugs immediately** (Phase 1 — weeks 1-2)
2. **Introduces React only where it adds clear value** (Phase 2 — weeks 3-6)
3. **Gradually migrates Vue admin → React** as features are touched (Phase 3 — ongoing)
This is the industry-standard approach for WordPress plugins at this stage. Here's why and how.
---
## Why NOT a Full React Rebuild
| Factor | Full React Rebuild | Incremental Modernization |
|--------|--------------------|---------------------------|
| **Time** | 3-4 months minimum | 2-4 weeks for critical fixes |
| **Risk** | Complete rewrite = complete regression risk | Fixes are targeted and testable |
| **Revenue** | No updates for 3-4 months | Continuous delivery |
| **WordPress ecosystem** | Fighting against WP conventions | Working with WP conventions |
| **SEO / Accessibility** | Client-side rendering = SEO problems for public forms | SSR = perfect SEO & accessibility |
| **Complexity** | Need build pipeline, state management, API layer | Build on what works |
| **Team onboarding** | Entire codebase unfamiliar | Familiar patterns + gradual React intro |
### Industry Precedent
- **WooCommerce** — Still PHP/SSR for storefront, React only for admin (block editor, settings). Did NOT rewrite frontend in React.
- **Easy Digital Downloads** — Same approach. PHP templates + React for admin features.
- **GiveWP** — PHP/SSR frontend, React for admin dashboard and form builder.
- **Gravity Forms** — PHP/SSR rendering, React for the form builder only.
**The pattern is clear:** WordPress payment/e-commerce plugins keep **SSR for public-facing forms** (SEO, accessibility, speed, no-JS fallback) and use **React for admin UX** (form builder, dashboards, settings).
---
## Recommended Architecture
### What to KEEP (SSR / PHP)
```
✅ Public form rendering (shortcode → PHP template)
✅ Order processing & payment flow (PHP backend)
✅ Email notifications (PHP wp_mail)
✅ Database layer (custom tables via dbDelta)
✅ Webhook handlers (REST routes in PHP)
✅ Thank-you page (PHP template)
```
**Why:** Server-side rendered forms are the **correct choice** for WordPress checkout forms. They provide:
- Zero JavaScript dependency (forms work without JS)
- Perfect SEO (crawlers see full HTML)
- Fast initial paint (no JS bundle download needed)
- Native WordPress shortcode/block integration
- Accessibility out of the box (screen readers work)
### What to MIGRATE to React
```
🔄 Admin form builder (currently partial Vue → full React)
🔄 Admin dashboard / analytics (new)
🔄 Admin order details view (currently Handlebars → React)
🔄 Admin settings pages (currently WPCFTO → custom React)
🔄 Gutenberg block (new — React is required)
```
### What to ADD as React
```
🆕 React-powered shortcode replacement (as <script> islands)
🆕 React-based order tracking widget for customer portal
🆕 React-based product catalog (optional)
```
---
## Implementation Plan
### Phase 1: Critical Fixes & Stabilization (Weeks 1-2)
**Goal:** Make the existing plugin production-ready.
```
Week 1 — Critical Bug Fixes:
├── Fix Customer::update() undefined variables
├── Fix Order::delete() $id → $order_id
├── Fix Order::bulk_delete() loop variable
├── Fix Email::send_email() class reference
├── Fix Paypal::auto_cancel_order_on_timeout() import
├── Fix BankTransfer unique_code triple-call
├── Fix color field label ('Number' → 'Color')
└── Add missing nonce checks (Customer::tabledata)
Week 2 — Performance & Security:
├── Remove flush_rewrite_rules() from init → activation hook only
├── Replace maybe_serialize() in cookies with json_encode()
├── Add PayPal webhook signature verification
├── Add rate limiting on public AJAX endpoints
├── Cache currency/country JSON reads in static vars
├── Add pagination to Customer::tabledata
├── Optimize Order::tabledata queries (COUNT + GROUP BY)
└── Add uninstall.php for cleanup
```
### Phase 2: React Admin Foundation (Weeks 3-6)
**Goal:** Set up React build pipeline and migrate the most impactful admin pages.
```
Week 3 — Build Pipeline:
├── Set up @wordpress/scripts (wp-scripts) build system
├── Configure webpack with React Fast Refresh
├── Create React component library structure
├── Set up API layer (fetch wrapper with nonce handling)
└── Create admin page shell component (sidebar + routing)
Week 4 — Form Builder (highest admin ROI):
├── Build drag-and-drop field palette (React)
├── Build field settings panel (React)
├── Build live preview canvas (React)
├── Connect to existing PHP save endpoints
└── Replace current Vue/Classic Editor metabox
Week 5 — Order Management & Dashboard:
├── Build order list page with filters (React + TanStack Table)
├── Build order detail view (replace Handlebars templates)
├── Build status change workflow with timeline
├── Build simple analytics dashboard (orders, revenue, charts)
└── Build notification log viewer
Week 6 — Settings & Product Editor:
├── Build global settings page (replace WPCFTO dependency)
├── Build product editor page (replace classic editor metaboxes)
├── Build coupon editor page
├── Build access items manager
└── Build license management page
```
### Phase 3: Frontend Enhancements (Weeks 7-10)
**Goal:** Add React-powered frontend features while keeping SSR as the default.
```
Week 7-8 — React Island Architecture:
├── Create a render_php() method (existing SSR — default)
├── Create a render_react() method (new — optional)
├── Build React form renderer component
├── Implement "island hydration" — React attaches to SSR HTML
├── Add setting: "Render Mode: Classic (SSR) | Modern (React)"
└── Add multi-step form navigation (missing in current SSR)
Week 9 — Gutenberg Block:
├── Register formipay/form block with block.json
├── Block renders shortcode server-side in edit.js preview
├── Full React experience in editor
├── Settings panel in InspectorControls
└── Replace shortcode-only workflow
Week 10 — Customer Portal:
├── Build customer order history page (React)
├── Build order detail / download access page (React)
├── Build access link request form (React)
├── Integrate with WordPress user accounts
└── Shortcode [formipay_my_orders] for portal
```
### Phase 4: Complete Missing Features (Weeks 11-16)
```
Week 11-12 — Payment & Commerce:
├── Implement ExchangeRateAPI (currently empty)
├── Implement License API endpoints (currently stubs)
├── Add Stripe payment gateway
├── Add tax calculation engine
└── Add product variations on frontend
Week 13-14 — Stock & Shipping:
├── Implement stock management (decrement, validation, messages)
├── Add weight-based shipping calculation
├── Add shipping zone support
└── Add order fulfillment workflow
Week 15-16 — Advanced Features:
├── Build donation form mode (pay-what-you-want, suggested amounts)
├── Add PDF invoice generation
├── Add CSV export for orders/customers
├── Add webhook notification system
└── Add analytics & reporting dashboard
```
---
## Technical Architecture Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ WordPress Plugin │
│ │
│ ┌──────────────────────┐ ┌──────────────────────────┐ │
│ │ PHP Backend Core │ │ React Admin (wp-scripts)│ │
│ │ │ │ │ │
│ │ • Custom Post Types │◄──►│ • Form Builder │ │
│ │ • Custom DB Tables │ │ • Order Management │ │
│ │ • Payment Gateways │ │ • Product Editor │ │
│ │ • Email System │ │ • Settings Pages │ │
│ │ • REST API Endpoints│ │ • Dashboard/Analytics │ │
│ │ • Webhook Handlers │ │ • License Manager │ │
│ │ • Cron Jobs │ │ • Notification Log │ │
│ └──────────┬───────────┘ └──────────────────────────┘ │
│ │ │
│ ┌──────────▼───────────┐ ┌──────────────────────────┐ │
│ │ SSR Form Renderer │ │ React Frontend Islands │ │
│ │ (PHP + Shortcode) │ │ (Optional Enhancement) │ │
│ │ │ │ │ │
│ │ • [formipay] render │ │ • Multi-step navigation │ │
│ │ • Thank-you page │ │ • Real-time validation │ │
│ │ • Payment confirm │ │ • Customer portal │ │
│ │ • No-JS fallback ✅ │ │ • Gutenberg block │ │
│ └──────────────────────┘ └──────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Shared API Layer (WordPress REST API + admin-ajax) │ │
│ │ • /wp-json/formipay/v1/* │ │
│ │ • admin-ajax.php actions (backward compat) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## Build Toolchain
```
formipay/
├── package.json # @wordpress/scripts + deps
├── webpack.config.js # Extend wp-scripts config
├── .eslintrc.js # ESLint with WP rules
├── .prettierrc # Prettier config
├── tsconfig.json # TypeScript (recommended)
├── src/ # React source code
│ ├── admin/ # Admin React app
│ │ ├── index.js # Entry point
│ │ ├── components/ # Shared components
│ │ │ ├── DataTable/ # Reusable table component
│ │ │ ├── SettingsPanel/ # Settings form builder
│ │ │ ├── Modal/ # Confirmation modals
│ │ │ └── StatusBadge/ # Order status badges
│ │ ├── pages/ # Page components
│ │ │ ├── Dashboard/
│ │ │ ├── FormBuilder/
│ │ │ ├── Orders/
│ │ │ ├── Products/
│ │ │ ├── Customers/
│ │ │ ├── Coupons/
│ │ │ ├── Licenses/
│ │ │ ├── AccessItems/
│ │ │ └── Settings/
│ │ ├── hooks/ # Custom React hooks
│ │ │ ├── useOrders.js
│ │ │ ├── useProducts.js
│ │ │ └── useSettings.js
│ │ └── api/ # API client
│ │ ├── client.js # Fetch wrapper with nonce
│ │ ├── orders.js
│ │ ├── products.js
│ │ └── settings.js
│ │
│ ├── frontend/ # Frontend React islands
│ │ ├── blocks/ # Gutenberg blocks
│ │ │ └── formipay-form/
│ │ │ ├── block.json
│ │ │ ├── edit.jsx
│ │ │ └── view.js
│ │ └── widgets/ # Embeddable widgets
│ │ ├── CustomerPortal/
│ │ └── OrderTracker/
│ │
│ └── shared/ # Shared between admin/frontend
│ ├── utils/
│ └── constants/
├── assets/build/ # Compiled output (gitignored)
│ ├── admin.bundle.js
│ ├── admin.bundle.css
│ ├── frontend.bundle.js
│ └── blocks/
├── includes/ # PHP backend (existing, fixed)
├── admin/ # PHP admin pages (kept for PHP render)
├── public/ # Public templates (SSR)
├── vendor/ # Composer dependencies (new)
└── templates/ # WordPress templates
```
### `package.json` (minimal)
```json
{
"name": "formipay",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start",
"lint:js": "wp-scripts lint-js src/",
"lint:css": "wp-scripts lint-style",
"format": "wp-scripts format src/"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@wordpress/api-fetch": "^6.0.0",
"@wordpress/components": "^25.0.0",
"@wordpress/data": "^9.0.0",
"@wordpress/element": "^5.0.0",
"@wordpress/i18n": "^4.0.0",
"@tanstack/react-table": "^8.0.0",
"@tanstack/react-query": "^5.0.0",
"recharts": "^2.10.0"
},
"devDependencies": {
"@wordpress/scripts": "^27.0.0"
}
}
```
---
## React Frontend Integration Strategy
### Pattern: React Islands (Hybrid SSR + React)
The key pattern is **"islands of interactivity"** — the PHP renders the initial HTML (SSR), and React optionally hydrates specific sections:
```php
// Render.php — The shortcode handler
public function shortcode($atts) {
// ... existing SSR rendering (default) ...
// Option: If React mode is enabled, render a mount point instead
if ($render_mode === 'react') {
return sprintf(
'<div id="formipay-react-form-%d" data-form-id="%d" data-nonce="%s"></div>',
$post_id,
$post_id,
wp_create_nonce('formipay_order_submit')
);
}
// Default: existing PHP-rendered form (SSR)
return $this->render_php_form($post_id);
}
```
```jsx
// src/frontend/widgets/FormRenderer.jsx
import { createRoot } from '@wordpress/element';
import Form from './components/Form';
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[id^="formipay-react-form-"]').forEach(el => {
const formId = parseInt(el.dataset.formId);
const nonce = el.dataset.nonce;
createRoot(el).render(<Form formId={formId} nonce={nonce} />);
});
});
```
This gives you:
- **Backward compatibility** — existing `[formipay]` shortcodes keep working with SSR
- **Opt-in React** — new features use React when enabled
- **Gutenberg block** — renders React in editor, SSR or React on frontend
- **No lock-in** — each form can use either mode independently
---
## What to Do With the Vue Code
The existing Vue admin code (`admin/assets/vue/`) should be **deprecated, not deleted immediately**:
1. **Keep** Vue code working during Phase 1 (critical fixes)
2. **Build** React equivalents in Phase 2 alongside Vue
3. **Switch** admin pages to React one at a time (form builder first)
4. **Remove** Vue code once all pages are migrated in Phase 3
Do NOT try to mix Vue and React on the same page — they'll conflict. Migrate page-by-page.
---
## Gutenberg Block Strategy
```jsx
// src/frontend/blocks/formipay-form/block.json
{
"apiVersion": 3,
"name": "formipay/form",
"title": "Formipay Form",
"category": "widgets",
"attributes": {
"formId": { "type": "number", "default": 0 },
"renderMode": { "type": "string", "default": "ssr" }
},
"supports": {
"html": false,
"align": true
},
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css",
"render": "file:./render.php"
}
```
The `render.php` file uses Server-Side Rendering (required by WordPress for blocks), which calls the existing PHP form renderer. The editor experience (`edit.jsx`) is fully React.
---
## Testing Strategy
```
Phase 1: Manual testing of all bug fixes
Phase 2: PHPUnit for backend + Jest for React components
Phase 3: E2E tests with Playwright for critical flows
Phase 4: CI/CD with GitHub Actions
Coverage targets:
├── Backend (PHP): 80% on payment + order paths
├── Admin (React): 70% on components
├── Frontend: E2E for form submission, payment, thank-you
└── API: Integration tests for all REST endpoints
```
---
## Cost-Benefit Summary
| Approach | Time | Cost | Risk | Deliverable |
|----------|------|------|------|-------------|
| **Full React rebuild** | 3-4 months | Very high | Very high | Complete rewrite, likely with new bugs |
| **Keep PHP + fix only** | 2-3 weeks | Low | Low | Fixes bugs, no UX improvement |
| **Incremental modernization** ✅ | 4-6 weeks for Phases 1-2 | Medium | Low-Medium | Bugs fixed + modern admin UX |
### Recommended: **Incremental Modernization**
This approach:
1. **Delivers value immediately** — critical bugs fixed in Week 1
2. **Reduces risk** — changes are targeted and testable
3. **Follows WordPress conventions** — SSR forms, React admin
4. **Enables future growth** — React foundation for new features
5. **Matches industry patterns** — same approach as WooCommerce, GiveWP, Gravity Forms
---
*End of recommendation.*

View File

@@ -851,11 +851,46 @@ class Form {
'group' => 'started',
'description' => __( 'Add static product or custom item to form as default and non-editable item in order items.', 'formipay' )
],
'static_products' => [
'type' => 'autocomplete',
'post_type' => ['formipay-product'],
'label' => __( 'Assign Product', 'formipay' ),
'description' => __( 'Selected products will be added to the order items automatically, even if it is not added to cart.', 'formipay' )
// 'static_products' => [
// 'type' => 'autocomplete',
// 'post_type' => ['formipay-product'],
// 'label' => __( 'Assign Product', 'formipay' ),
// 'description' => __( 'Selected products will be added to the order items automatically, even if it is not added to cart.', 'formipay' )
// ],
'static_product' => [
'type' => 'repeater',
'label' => __( 'Static Product', 'formipay' ),
'description' => __( 'Selected products will be added to the order items automatically, even if it is not added to cart.', 'formipay' ),
'fields' => [
'default_qty' => [
'type' => 'number',
'label' => __( 'Default Qty', 'formipay' ),
'description' => __( 'Set default quantity', 'formipay' )
],
'editable_qty' => [
'type' => 'checkbox',
'label' => __( 'Editable Qty', 'formipay' ),
'description' => __( 'User can set quantity as they want', 'formipay' )
],
'minimum_qty' => [
'type' => 'number',
'label' => __( 'Minimum Qty', 'formipay' ),
'description' => __( 'Restrict buyer to set below this number', 'formipay' ),
'dependency' => [
'key' => 'editable_qty',
'value' => 'not_empty'
]
],
'maximum_qty' => [
'type' => 'number',
'label' => __( 'Minimum Qty', 'formipay' ),
'description' => __( 'Restrict buyer to set below this number', 'formipay' ),
'dependency' => [
'key' => 'editable_qty',
'value' => 'not_empty'
]
],
]
],
'static_items' => [
'type' => 'repeater',

View File

@@ -15,7 +15,9 @@ class Order {
private $order_details;
private $chosen_currency;
private $chosen_currency; // reserved (not used yet)
private $currency; // 3-letter currency code from request (e.g., IDR, USD)
/**
* Initializes the plugin by setting filters and administration functions.
@@ -90,7 +92,7 @@ class Order {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$order_meta_data = isset($_REQUEST['meta_data']) ? wp_unslash($_REQUEST['meta_data']) : [];
$purpose = isset($_REQUEST['purpose']) ? sanitize_text_field(wp_unslash($_REQUEST['purpose'])) : '';
$this->currency = isset($_REQUEST['currency']) ? wp_unslash($_REQUEST['currency']) : formipay_default_currency('symbol');
$this->currency = isset($_REQUEST['currency']) ? sanitize_text_field( wp_unslash($_REQUEST['currency']) ) : (string) formipay_default_currency('code');
$this->form_id = $form_id;
@@ -221,85 +223,49 @@ class Order {
$details = [];
// $product_price = floatval(formipay_get_post_meta($this->form_id, 'product_price'));
// $details[] = [
// 'item' => html_entity_decode(get_the_title($this->form_id)),
// 'amount' => $product_price,
// 'qty' => (int) $this->order_data['qty'],
// 'subtotal' => floatval($product_price) * intval($this->order_data['qty']),
// 'context' => 'main'
// ];
// $check_fields = formipay_get_post_meta($this->form_id, 'formipay_settings');
// if(!empty($check_fields['fields'])){
// foreach($check_fields['fields'] as $field){
// // if($field['field_type'] == 'select'){
// if(in_array($field['field_type'], ['select','checkbox', 'radio'])) {
// $options = $field['field_options'];
// if(!empty($options)){
// foreach($options as $option){
// $option_value = ($field['show_toggle']['value'] && '' !== $option['value']) ? $option['value'] : $option['label'];
// if(!empty($this->order_data[$field['field_id']])) {
// $field_value = $this->order_data[$field['field_id']];
// if($field['field_type'] == 'select'){
// $field_value = ($field['show_toggle']['value']) ?
// $this->order_data[$field['field_id']]['value'] :
// $this->order_data[$field['field_id']]['label'];
// }
// $field_value = explode(',', $field_value);
// $context = 'no-context';
// if(floatval($option['amount']) < 0){
// $context = 'sub';
// }elseif(floatval($option['amount']) > 0){
// $context = 'add';
// }
// if(!empty($field_value) && $field['show_toggle']['amount'] == 'yes'){
// foreach($field_value as $f_value){
// if($option_value == $f_value){
// $qty = ($option['qty'] == 'yes') ? $this->order_data['qty'] : 1;
// $details[] = [
// 'item' => $field['label'] .' - '. $option['label'],
// 'amount' => floatval($option['amount']),
// 'qty' => (int) $qty,
// 'subtotal' => floatval($option['amount']) * intval($qty),
// 'context' => $context
// ];
// }
// }
// }
// }
// }
// }
// }
// }
// }
/**
* Cart items (not implemented yet)
*/
/**
* Attached Product
*/
// Ensure currency code is present; fallback to form default currency code
if (empty($this->currency)) {
$default_currency_full = formipay_get_post_meta($this->form_id, 'default_currencies'); // e.g., "IDR:::Indonesian rupiah:::Rp"
$parts = explode(':::', (string) $default_currency_full);
$this->currency = $parts[0] ?? 'IDR';
}
// Attached static products (qty = 1 each in this case)
$products = formipay_get_post_meta($this->form_id, 'static_products');
if(!empty($products)){
$products = explode(',', $products);
foreach($products as $product_id){
$product_data = formipay_get_post_meta($product_id);
$regular_price = formipay_get_post_meta($product_id, 'setting_product_price_regular_'.$this->currency);
$sale_price = formipay_get_post_meta($product_id, 'setting_product_price_sale_'.$this->currency);
$this_item = [
if (!empty($products)) {
$products = array_filter(array_map('absint', explode(',', (string) $products)));
foreach ($products as $product_id) {
$regular_key = 'setting_product_price_regular_' . $this->currency;
$sale_key = 'setting_product_price_sale_' . $this->currency;
$regular_price = formipay_get_post_meta($product_id, $regular_key);
$sale_price = formipay_get_post_meta($product_id, $sale_key);
$price = ($sale_price !== '' && $sale_price !== null) ? (float) $sale_price : (float) $regular_price;
$details[] = [
'item' => html_entity_decode(get_the_title($product_id)),
'amount' => (float) $sale_price ?: $regular_price,
'amount' => $price,
'qty' => 1,
'subtotal' => (float) $sale_price ?: $regular_price,
'subtotal' => $price,
'context' => 'product',
];
}
}
// Static items (fees/bonuses), currency-aware amounts
$raw_items = formipay_get_post_meta($this->form_id, 'static_items');
if (!empty($raw_items)) {
$items = json_decode((string) $raw_items, true) ?: [];
foreach ($items as $it) {
$label = $it['label'] ?? 'Item';
$qty = (int) ($it['quantity'] ?? 1);
$key = 'amount_' . $this->currency;
$amt = (float) ($it[$key] ?? 0);
$details[] = [
'item' => $label,
'amount' => $amt,
'qty' => $qty,
'subtotal' => $amt * $qty,
'context' => 'item',
];
}
}

View File

@@ -313,55 +313,32 @@ class Render {
<?php
break;
case 'order_review':
$cart = $this->build_cart_from_meta($post_id);
?>
<div class="form-calculation form-calculate-<?php echo esc_attr($post_id); ?>">
<h4><?php echo esc_html(formipay_get_post_meta($post_id, 'order_review_title')); ?></h4>
<table id="formipay-review-order">
<tbody>
<tr class="formipay-product-row formipay-item-row main">
<?php
$price = formipay_get_post_meta($post_id, 'product_price');
if(formipay_get_post_meta($post_id, 'product_quantity_toggle') == 'on') {
$stock = formipay_get_post_meta($post_id, 'product_stock');
$stock_html = '';
if($stock > -1){
$stock = ' max="'.$stock.'"';
}
?>
<?php foreach ($cart['lines'] as $line): ?>
<tr class="formipay-item-row <?php echo $line['type']==='product' ? 'formipay-product-row' : 'formipay-static-item-row'; ?>">
<th>
<?php echo esc_html(get_the_title($post_id)); ?> <br>
<span class="product-qty-wrapper">
<button type="button" class="product-qty qty-min">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14" />
</svg>
</button>
<input type="number" class="product-qty formipay-qty-input" value="<?php echo intval(formipay_get_post_meta($post_id, 'product_quantity_range')); ?>" step="<?php echo intval(formipay_get_post_meta($post_id, 'product_quantity_range')); ?>" min="<?php echo intval(formipay_get_post_meta($post_id, 'product_minimum_purchase')); ?>"<?php echo esc_html($stock) ?>>
<button type="button" class="product-qty qty-plus">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v14m-7-7h14" />
</svg>
</button>
</span>
<?php echo esc_html($line['name']); ?>
<input type="hidden" class="formipay-qty-input" value="<?php echo (int) $line['qty']; ?>">
<?php if ((int) $line['qty'] > 1): ?>
<div class="formipay-qty-note">x<?php echo (int) $line['qty']; ?></div>
<?php endif; ?>
</th>
<td class="product_price"><?php echo esc_html(formipay_price_format(floatval($price) * intval(formipay_get_post_meta($post_id, 'product_quantity_range')), $post_id)); ?></td>
<?php
} else {
?>
<th>
<?php echo esc_html(get_the_title($post_id)); ?> <input type="hidden" class="formipay-qty-input" value="1">
</th>
<td><?php echo esc_html(formipay_price_format(floatval($price), $post_id)); ?></td>
<?php
}
?>
<td class="product_price"><?php echo esc_html(formipay_price_format((float) $line['total'], $post_id)); ?></td>
</tr>
<?php endforeach; ?>
<tr class="formipay-total-row">
<td colspan="2"></td>
<th><?php echo esc_html__( 'Subtotal', 'formipay' ); ?></th>
<td><?php echo esc_html(formipay_price_format((float) $cart['subtotal'], $post_id)); ?></td>
</tr>
<tr class="formipay-grand-total-row">
<th><?php echo esc_html__( 'Total', 'formipay' ); ?></th>
<td class="grand_total"><?php echo esc_html(formipay_price_format(floatval($price), $post_id)); ?></td>
<td class="grand_total"><?php echo esc_html(formipay_price_format((float) $cart['grand'], $post_id)); ?></td>
</tr>
</tbody>
</table>
@@ -369,17 +346,16 @@ class Render {
<?php
break;
case 'submit_button':
$cart = $cart ?? $this->build_cart_from_meta($post_id);
$grand_display = formipay_price_format((float) $cart['grand'], $post_id);
?>
<button type="submit" class="formipay-submit-button"
data-button-text="<?php echo esc_attr(formipay_get_post_meta($post_id, 'button_text')); ?>"
style="width: <?php echo formipay_get_post_meta($post_id, 'button_width') == 'fit-content' ? 'fit-content' : '100%' ?>;
margin-left: <?php echo formipay_get_post_meta($post_id, 'button_position') !== 'left' ? 'auto' : 'unset' ?>;
margin-right: <?php echo formipay_get_post_meta($post_id, 'button_position') !== 'right' ? 'auto' : 'unset' ?>;">
<?php echo esc_html(formipay_get_post_meta($post_id, 'button_text')); ?> - <?php echo esc_html(formipay_price_format(floatval($price), $post_id)); ?>
<?php echo esc_html(formipay_get_post_meta($post_id, 'button_text')); ?> - <?php echo esc_html($grand_display); ?>
</button>
<?php
break;
case 'submit_response_notice':
@@ -432,6 +408,92 @@ class Render {
}
}
/**
* Build a simple cart from static products and static items meta
*/
private function build_cart_from_meta($post_id){
$currency_full = formipay_post_currency($post_id); // e.g., "IDR:::Indonesian rupiah:::Rp"
$parts = explode(':::', (string) $currency_full);
$currency_code = $parts[0] ?? 'IDR';
$lines = [];
$subtotal = 0.0;
// 1) Static PRODUCTS (IDs stored as comma-separated string)
$ids_raw = (string) formipay_get_post_meta($post_id, 'static_products');
$ids = array_filter(array_map('absint', explode(',', $ids_raw)));
foreach ($ids as $pid) {
$pname = get_the_title($pid);
$price = $this->get_product_price_for_currency($pid, $currency_code);
$qty = 1; // non-donation, no in-cart items => default 1
$line_total = (float) $price * $qty;
$subtotal += $line_total;
$lines[] = [
'type' => 'product',
'id' => $pid,
'name' => $pname,
'qty' => $qty,
'unit' => (float) $price,
'total'=> $line_total,
];
}
// 2) Static ITEMs (JSON array with currency-specific amounts)
$raw = formipay_get_post_meta($post_id, 'static_items');
if ($raw) {
$items = json_decode((string) $raw, true) ?: [];
foreach ($items as $it) {
$label = $it['label'] ?? 'Item';
$qty = (int) ($it['quantity'] ?? 1);
$k = 'amount_' . $currency_code;
$amt = (float) ($it[$k] ?? 0);
$line_total = $amt * $qty;
$subtotal += $line_total;
$lines[] = [
'type' => 'item',
'id' => null,
'name' => $label,
'qty' => $qty,
'unit' => $amt,
'total'=> $line_total,
];
}
}
// Placeholders for future rules
$discount = 0.0;
$shipping = 0.0;
$tax = 0.0;
$grand = max($subtotal - $discount + $shipping + $tax, 0);
return compact('currency_code','lines','subtotal','discount','shipping','tax','grand');
}
/**
* Resolve a product's price for a given currency code using common meta keys.
* Fallback order: product_price_{CODE} → price_{CODE} → product_price → price → 0
*/
private function get_product_price_for_currency($product_id, $currency_code){
$cands = [
'product_price_' . $currency_code,
'price_' . $currency_code,
'product_price',
'price',
];
foreach ($cands as $key) {
$val = formipay_get_post_meta($product_id, $key);
if ($val !== '' && $val !== null) {
return (float) $val;
}
}
return 0.0;
}
/**
* Render payment options
*/
@@ -602,19 +664,121 @@ class Render {
}
/**
* Resolve currency UI/config for a given 3-letter currency code from wp_options.
*/
private function resolve_currency_config($currency_code){
$opts = get_option('formipay_settings', []);
// Defaults from global default_* settings
$default_full = isset($opts['default_currency']) ? (string)$opts['default_currency'] : 'IDR:::Indonesian rupiah:::Rp';
$def_parts = explode(':::', $default_full);
$def_symbol = $def_parts[2] ?? '';
$cfg = [
'currency' => $def_symbol,
'decimal_digits' => isset($opts['default_currency_decimal_digits']) ? (int)$opts['default_currency_decimal_digits'] : 2,
'decimal_symbol' => isset($opts['default_currency_decimal_symbol']) ? (string)$opts['default_currency_decimal_symbol'] : '.',
'thousand_separator' => isset($opts['default_currency_thousand_separator']) ? (string)$opts['default_currency_thousand_separator'] : ',',
];
if (!empty($opts['multicurrencies']) && is_array($opts['multicurrencies'])) {
foreach ($opts['multicurrencies'] as $mc) {
if (empty($mc['currency'])) continue;
$parts = explode(':::', (string)$mc['currency']);
$code = $parts[0] ?? '';
if (strtoupper($code) !== strtoupper($currency_code)) continue;
$symbol = $parts[2] ?? '';
if ($symbol !== '') $cfg['currency'] = $symbol;
if ($mc['decimal_digits'] !== '') $cfg['decimal_digits'] = (int)$mc['decimal_digits'];
if ($mc['decimal_symbol'] !== '') $cfg['decimal_symbol'] = (string)$mc['decimal_symbol'];
if ($mc['thousand_separator'] !== '') $cfg['thousand_separator'] = (string)$mc['thousand_separator'];
break;
}
}
return $cfg;
}
/**
* Build the allowed currency list for a form, including UI config per currency.
* Uses form meta 'allowed_currencies' (JSON array of "CODE:::Title:::Symbol")
* and 'default_currencies' (single string) to set default.
*/
private function resolve_allowed_currencies($post_id){
// Allowed on the form
$raw = formipay_get_post_meta($post_id, 'allowed_currencies');
$allowed = [];
if (!empty($raw)) {
$arr = json_decode((string)$raw, true);
if (is_array($arr)) $allowed = $arr;
}
// Fallback to all global currencies if the form has none
if (empty($allowed)) {
$opts = get_option('formipay_settings', []);
if (!empty($opts['multicurrencies']) && is_array($opts['multicurrencies'])) {
foreach ($opts['multicurrencies'] as $mc) {
if (!empty($mc['currency'])) $allowed[] = (string)$mc['currency'];
}
}
}
// Default for the form
$default_full = formipay_get_post_meta($post_id, 'default_currencies');
if (empty($default_full)) {
$opts = get_option('formipay_settings', []);
$default_full = $opts['default_currency'] ?? 'IDR:::Indonesian rupiah:::Rp';
}
$def_parts = explode(':::', (string)$default_full);
$default_code = $def_parts[0] ?? 'IDR';
// Compose structured list
$list = [];
foreach ($allowed as $cur_full) {
$parts = explode(':::', (string)$cur_full);
$code = $parts[0] ?? '';
$title = $parts[1] ?? '';
$symbol = $parts[2] ?? '';
if (!$code) continue;
$cfg = $this->resolve_currency_config($code);
if ($symbol !== '') $cfg['currency'] = $symbol; // prefer explicit symbol in the tuple
$list[] = [
'code' => $code,
'title' => $title,
'symbol' => $cfg['currency'],
'decimal_digits' => (int)$cfg['decimal_digits'],
'decimal_symbol' => (string)$cfg['decimal_symbol'],
'thousand_separator' => (string)$cfg['thousand_separator'],
];
}
return [
'default_code' => $default_code,
'list' => $list,
];
}
private function get_form_data(){
$form_data = [];
foreach (array_unique(self::$form_ids) as $post_id) {
$allowed_currency_pack = $this->resolve_allowed_currencies($post_id);
$currency_code = $allowed_currency_pack['default_code'];
$currency_cfg = $this->resolve_currency_config($currency_code);
$form_data[$post_id] = [
'form_id' => $post_id,
'currency' => formipay_post_currency($post_id),
'currency_code' => $currency_code, // active on load
'currency' => $currency_cfg['currency'],
'decimal_digits' => $currency_cfg['decimal_digits'],
'decimal_symbol' => $currency_cfg['decimal_symbol'],
'thousand_separator' => $currency_cfg['thousand_separator'],
'allowed_currency_pack' => $allowed_currency_pack,
'buyer_phone_field' => formipay_get_post_meta($post_id, 'buyer_phone'),
'buyer_country_field' => formipay_get_post_meta($post_id, 'buyer_country'),
'buyer_phone_allow' => (bool) formipay_get_post_meta($post_id, 'buyer_allow_choose_country_code'),
'buyer_phone_country_code' => formipay_get_post_meta($post_id, 'buyer_phone_country_code'),
'decimal_digits' => formipay_get_post_meta($post_id, 'product_currency_decimal_digits'),
'decimal_symbol' => formipay_get_post_meta($post_id, 'product_currency_decimal_symbol'),
'thousand_separator' => formipay_get_post_meta($post_id, 'product_currency_thousand_separator'),
'notice_empty_text_message' => formipay_get_post_meta($post_id, 'empty_required_text_field'),
'notice_empty_select_message' => formipay_get_post_meta($post_id, 'empty_required_select_field'),
'notice_empty_agreement_message' => formipay_get_post_meta($post_id, 'empty_required_agreement_field'),
@@ -628,7 +792,10 @@ class Render {
'trigger_selector' => formipay_get_post_meta($post_id, 'popup_click_selector') ?
formipay_get_post_meta($post_id, 'popup_trigger_selector') :
'.formipay-open-popup-button',
'modal_selector' => '#formipay-popup-' . $post_id
'modal_selector' => '#formipay-popup-' . $post_id,
'static_products' => array_filter(array_map('absint', explode(',', (string) formipay_get_post_meta($post_id, 'static_products')))),
'static_items' => json_decode((string) formipay_get_post_meta($post_id, 'static_items'), true) ?: [],
'currency_code' => (function($c){ $p = explode(':::', (string)$c); return $p[0] ?? 'IDR'; })(formipay_post_currency($post_id)),
];
}

View File

@@ -22,8 +22,9 @@ jQuery(function($){
return valid;
}
// Optional safety: guard for bad inputs to price_format
function price_format(nStr) {
nStr = parseFloat(nStr).toFixed(formipay.decimal_digits) + '';
nStr = isNaN(parseFloat(nStr)) ? 0 : parseFloat(nStr); nStr = nStr.toFixed(formipay.decimal_digits) + '';
var x = nStr.split('.');
var x1 = x[0];
var x2 = x.length > 1 ? formipay.decimal_symbol + x[1] : '';
@@ -34,7 +35,7 @@ jQuery(function($){
return formipay.currency + ' ' + x1 + x2;
}
$('.product-price-row').find('td').html(price_format($('#product_price').val()));
// Removed unconditional product price write: in static-products mode there is no #product_price and this forced a 0.
$('.formipay-payment-option-group:first-child').find('input').trigger('click');
// PAGE BREAK
@@ -220,7 +221,7 @@ jQuery(function($){
form_inputs.append('nonce', formipay_form.nonce);
form_inputs.append('data[qty]', $('.formipay-qty-input').val());
form_inputs.append('form_id', form_id);
form_inputs.append('currency', 'IDR');
form_inputs.append('currency', formipay.currency_code || 'IDR');
var $valid = true; // Initialize as true
@@ -374,27 +375,54 @@ jQuery(function($){
$(document).on('formipayCalculateAjaxSuccess', function(event, res, form, action) {
if(action == 'calculate') {
form.find('.formipay-item-row:not(.formipay-product-row):not(.formipay-total-row):not(.formipay-grand-total-row)').remove();
var product_price = res.items[0].subtotal;
var grand_total = res.total;
form.find('td.product_price').html(price_format(product_price));
form.find('td.grand_total').html(price_format(grand_total));
var button_text = form.find('.formipay-submit-button').data('button-text');
form.find('.formipay-submit-button').html(button_text + ' - ' + price_format(grand_total));
$.each(res.items, function(index, item){
if(index > 0){
// If backend returns structured items, rebuild dynamic rows
if(res && Array.isArray(res.items)){
// Remove previously injected rows (keep total/grand-total rows)
form.find('.formipay-item-row:not(.formipay-total-row):not(.formipay-grand-total-row)').remove();
// If server provides totals
if(typeof res.total !== 'undefined'){
form.find('td.grand_total').html(price_format(res.total));
}
// If server provides per-line subtotals
if(res.items.length){
// If server distinguishes a primary product subtotal, reflect it
// but do not rely on index 0 always being product
res.items.forEach(function(item, index){
var qty = '';
if('qty' in item && item.qty > 1){
qty = ' x '+item.qty;
}
$('table#formipay-review-order').find('.formipay-total-row').before(`
<tr class="formipay-item-row `+item.context+`">
<th>`+item.item+qty+`</th>
<td>`+price_format(item.subtotal)+`</td>
</tr>
`);
if('qty' in item && item.qty > 1){ qty = ' x ' + item.qty; }
// If context is 'product' and there is a dedicated product row, update it
if(item.context === 'product' && form.find('tr.formipay-product-row').length === 1 && index === 0){
form.find('td.product_price').html(price_format(item.subtotal));
return; // skip duplicate append below
}
// Append any extra dynamic items before the total row
form.find('table#formipay-review-order .formipay-total-row').before(
'<tr class="formipay-item-row '+(item.context||'')+'">\n' +
' <th>'+ item.item + qty +'</th>\n' +
' <td>'+ price_format(item.subtotal) +'</td>\n' +
'</tr>'
);
});
}
// Update button from new grand total
var grand_html = form.find('td.grand_total').text();
form.find('.formipay-submit-button').html(button_text + ' - ' + grand_html);
} else {
// Backend didnt send calculable payload; keep server-rendered table intact
var grand_html = form.find('td.grand_total').text();
if(grand_html){
form.find('.formipay-submit-button').html(button_text + ' - ' + grand_html);
} else {
// Fallback: no grand total cell; do nothing
}
}
}else if(action == 'checkout'){
var form_id = form.data('form-id');
if(res.success) {