Scaffold plugin: architecture contract, schema, settings, fee math
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
939193f258
12 files changed
+2081
No files matched your search
@@ -0,0 +1,6 @@
|
||||
vendor/
|
||||
node_modules/
|
||||
*.zip
|
||||
.phpunit.result.cache
|
||||
.DS_Store
|
||||
dist/
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "streamgoals/streamgoals-donate",
|
||||
"description": "PayPal donations, goals, and stream overlays for WordPress",
|
||||
"type": "wordpress-plugin",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"require": { "php": ">=8.1" },
|
||||
"require-dev": { "phpunit/phpunit": "^10.5" },
|
||||
"autoload": { "psr-4": { "StreamGoals\\": "includes/" } },
|
||||
"autoload-dev": { "psr-4": { "StreamGoals\\Tests\\": "tests/" } },
|
||||
"scripts": { "test": "phpunit" }
|
||||
}
|
||||
Generated
+1673
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,56 @@
|
||||
# StreamGoals Donate — Architecture Contract
|
||||
|
||||
Target: WordPress 7.x, PHP 8.1+. Single streamer per install. Built/deployed from Gitea (zip artifact, no runtime Composer).
|
||||
Namespace `StreamGoals\`, PSR-4 rooted at `includes/`. Text domain `streamgoals-donate`. Table prefix `{$wpdb->prefix}sg_`.
|
||||
|
||||
## Ground rules
|
||||
- **Money is integer minor units** (cents) plus an ISO-4217 currency. Never floats. Zero-decimal currencies (JPY) handled via `Core\Money`.
|
||||
- **Pure logic has no WordPress dependency** (`Core\Money`, `Core\FeeCalculator`, `Rules\*`) so it is unit-testable with plain PHPUnit.
|
||||
- WordPress-touching code (DB, REST, admin) sits behind small classes; escape all output, check capabilities (`manage_options`), nonces on admin posts.
|
||||
- Public endpoints are rate-limited and never expose the real name of an anonymous donor.
|
||||
|
||||
## Directory ownership (to avoid merge conflicts between agents)
|
||||
| Path | Owner |
|
||||
|---|---|
|
||||
| `includes/Core/*`, `streamgoals-donate.php`, `docs/ARCHITECTURE.md` | Foundation (done first) |
|
||||
| `includes/PayPal/*`, `includes/Rest/DonateController.php`, `includes/Rest/WebhookController.php`, `assets/donate/*` | Agent 1: PayPal + donate flow |
|
||||
| `includes/Rules/*`, `includes/Core/GoalRepository.php` consumers, `tests/Unit/Rules*` | Agent 2: Goals + rules engine |
|
||||
| `includes/Overlay/*`, `includes/Rest/OverlayController.php`, `assets/overlay/*` | Agent 3: Overlays |
|
||||
| `includes/Admin/*`, `assets/admin/*` | Later phase (wizard/admin UI) |
|
||||
|
||||
## Database (created by `Core\Installer`, version in option `sg_db_version`)
|
||||
- `sg_goals`: id, title, target_minor, currency, status(active|paused|complete), starts_at, ends_at NULL, counts_fees TINYINT(0 = base amount only), created_at
|
||||
- `sg_donations`: id, goal_id, paypal_order_id, paypal_capture_id UNIQUE, base_minor, fee_minor, total_minor, currency, donor_name, is_anonymous, message, status(pending|completed|refunded|reversed), moderation(approved|pending|rejected), created_at
|
||||
- `sg_rules`: id, goal_id, type(add_time|counter|bonus), config JSON, enabled, sort
|
||||
- `sg_rule_events`: id, rule_id, donation_id, delta (seconds or count), reversed TINYINT, created_at — UNIQUE(rule_id, donation_id) for idempotency
|
||||
- `sg_state`: goal_id, key, value — timer `ends_at`, counter totals, carry-over remainders
|
||||
- Options: `sg_settings` (paypal mode/client_id/secret, fee rates, moderation, anonymity policy, overlay token)
|
||||
|
||||
## Core interfaces (all in `includes/Core`)
|
||||
```php
|
||||
Money::fromDecimalString(string $amount, string $currency): Money; // "10.50" -> 1050
|
||||
FeeCalculator::grossUp(int $baseMinor, FeeRates $rates): int; // total so recipient nets base
|
||||
// total = ceil((base + fixed) / (1 - pct)) — rates configurable, default US 3.49% + 49c
|
||||
```
|
||||
Rules engine entry point (Agent 2 implements, Agent 1 calls after a capture is recorded and on refund):
|
||||
```php
|
||||
Rules\Engine::applyDonation(Donation $d): void; // idempotent via sg_rule_events UNIQUE
|
||||
Rules\Engine::reverseDonation(Donation $d): void; // honors per-rule "reverse on refund"
|
||||
```
|
||||
Rule `config` examples:
|
||||
```json
|
||||
{"type":"add_time","seconds_per_unit":60,"unit_minor":100,"cap_seconds_per_donation":null,"max_total_seconds":null,"basis":"base"}
|
||||
{"type":"counter","step":1,"per_minor":1000,"carry_over":true,"basis":"base","label":"Pies"}
|
||||
{"type":"bonus","min_minor":5000,"event":"big_donation"}
|
||||
```
|
||||
|
||||
## Public REST (namespace `streamgoals/v1`)
|
||||
- `POST /orders` (create order; server computes total incl. optional fee cover), `POST /orders/{id}/capture`
|
||||
- `POST /webhook` (PayPal signature verified; deduped by capture id)
|
||||
- `GET /overlay/{token}/{widget}` — ETag'd JSON, polled every 2–3 s; widgets: `goal`, `timer`, `counter`, `alerts`
|
||||
|
||||
## Fee cover
|
||||
A toggle on the donate form (default **off**). Form shows exact total before payment. Goal counts **base** amount by default (`counts_fees` per goal).
|
||||
|
||||
## Defaults
|
||||
Currency USD (configurable per goal). Sandbox mode is the default until the owner switches to Live.
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace StreamGoals\Core;
|
||||
|
||||
/**
|
||||
* Computes the amount a donor must pay so the recipient nets the intended base
|
||||
* after PayPal's fee: total = ceil((base + fixed) / (1 - pct)).
|
||||
*
|
||||
* Percent is expressed in basis points (349 = 3.49%) to avoid floats.
|
||||
*/
|
||||
final class FeeCalculator {
|
||||
|
||||
public function __construct( public readonly int $percentBps = 349, public readonly int $fixedMinor = 49 ) {
|
||||
if ( $percentBps < 0 || $percentBps >= 10000 || $fixedMinor < 0 ) {
|
||||
throw new \InvalidArgumentException( 'Invalid fee rates.' );
|
||||
}
|
||||
}
|
||||
|
||||
/** Total the donor pays so the recipient nets $baseMinor. */
|
||||
public function grossUp( int $baseMinor ): int {
|
||||
if ( $baseMinor <= 0 ) {
|
||||
return 0;
|
||||
}
|
||||
$num = ( $baseMinor + $this->fixedMinor ) * 10000;
|
||||
$den = 10000 - $this->percentBps;
|
||||
return intdiv( $num + $den - 1, $den ); // integer ceil
|
||||
}
|
||||
|
||||
/** The extra fee-cover portion for a base amount. */
|
||||
public function feeFor( int $baseMinor ): int {
|
||||
return $this->grossUp( $baseMinor ) - $baseMinor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace StreamGoals\Core;
|
||||
|
||||
/**
|
||||
* Creates/upgrades the plugin's tables. Safe to re-run (dbDelta).
|
||||
*/
|
||||
final class Installer {
|
||||
|
||||
public const DB_VERSION = '1';
|
||||
|
||||
public static function activate(): void {
|
||||
self::install_schema();
|
||||
if ( false === get_option( 'sg_settings' ) ) {
|
||||
add_option( 'sg_settings', Settings::defaults(), '', false );
|
||||
}
|
||||
}
|
||||
|
||||
/** Run on plugins_loaded so upgrades apply without re-activation. */
|
||||
public static function maybe_upgrade(): void {
|
||||
if ( get_option( 'sg_db_version' ) !== self::DB_VERSION ) {
|
||||
self::install_schema();
|
||||
}
|
||||
}
|
||||
|
||||
private static function install_schema(): void {
|
||||
global $wpdb;
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
$c = $wpdb->get_charset_collate();
|
||||
$p = $wpdb->prefix . 'sg_';
|
||||
|
||||
dbDelta( "CREATE TABLE {$p}goals (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
title VARCHAR(190) NOT NULL,
|
||||
target_minor BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
currency CHAR(3) NOT NULL DEFAULT 'USD',
|
||||
status VARCHAR(12) NOT NULL DEFAULT 'active',
|
||||
starts_at DATETIME NULL,
|
||||
ends_at DATETIME NULL,
|
||||
counts_fees TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
) $c;" );
|
||||
|
||||
dbDelta( "CREATE TABLE {$p}donations (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
goal_id BIGINT UNSIGNED NOT NULL,
|
||||
paypal_order_id VARCHAR(64) NOT NULL,
|
||||
paypal_capture_id VARCHAR(64) NULL,
|
||||
base_minor BIGINT UNSIGNED NOT NULL,
|
||||
fee_minor BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
total_minor BIGINT UNSIGNED NOT NULL,
|
||||
currency CHAR(3) NOT NULL,
|
||||
donor_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
is_anonymous TINYINT(1) NOT NULL DEFAULT 0,
|
||||
message VARCHAR(500) NOT NULL DEFAULT '',
|
||||
status VARCHAR(12) NOT NULL DEFAULT 'pending',
|
||||
moderation VARCHAR(10) NOT NULL DEFAULT 'approved',
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY capture (paypal_capture_id),
|
||||
KEY goal_status (goal_id,status),
|
||||
KEY order_id (paypal_order_id)
|
||||
) $c;" );
|
||||
|
||||
dbDelta( "CREATE TABLE {$p}rules (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
goal_id BIGINT UNSIGNED NOT NULL,
|
||||
type VARCHAR(16) NOT NULL,
|
||||
config LONGTEXT NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY goal_id (goal_id)
|
||||
) $c;" );
|
||||
|
||||
dbDelta( "CREATE TABLE {$p}rule_events (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
rule_id BIGINT UNSIGNED NOT NULL,
|
||||
donation_id BIGINT UNSIGNED NOT NULL,
|
||||
delta BIGINT NOT NULL DEFAULT 0,
|
||||
reversed TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY rule_donation (rule_id,donation_id)
|
||||
) $c;" );
|
||||
|
||||
dbDelta( "CREATE TABLE {$p}state (
|
||||
goal_id BIGINT UNSIGNED NOT NULL,
|
||||
skey VARCHAR(64) NOT NULL,
|
||||
svalue LONGTEXT NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (goal_id,skey)
|
||||
) $c;" );
|
||||
|
||||
update_option( 'sg_db_version', self::DB_VERSION );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace StreamGoals\Core;
|
||||
|
||||
/**
|
||||
* Integer minor-unit money value object. No floats anywhere.
|
||||
*/
|
||||
final class Money {
|
||||
|
||||
/** Currencies with no minor unit (PayPal-supported subset). */
|
||||
private const ZERO_DECIMAL = array( 'JPY', 'HUF', 'TWD' );
|
||||
|
||||
public function __construct( public readonly int $minor, public readonly string $currency ) {
|
||||
if ( $minor < 0 ) {
|
||||
throw new \InvalidArgumentException( 'Money cannot be negative.' );
|
||||
}
|
||||
}
|
||||
|
||||
public static function exponent( string $currency ): int {
|
||||
return in_array( strtoupper( $currency ), self::ZERO_DECIMAL, true ) ? 0 : 2;
|
||||
}
|
||||
|
||||
/** Parse a decimal string like "10.5" into minor units without float math. */
|
||||
public static function fromDecimalString( string $amount, string $currency ): self {
|
||||
$currency = strtoupper( $currency );
|
||||
$exp = self::exponent( $currency );
|
||||
if ( ! preg_match( '/^\d+(\.\d+)?$/', trim( $amount ) ) ) {
|
||||
throw new \InvalidArgumentException( 'Invalid amount.' );
|
||||
}
|
||||
[ $whole, $frac ] = array_pad( explode( '.', trim( $amount ), 2 ), 2, '' );
|
||||
if ( strlen( $frac ) > $exp ) {
|
||||
throw new \InvalidArgumentException( 'Too many decimal places.' );
|
||||
}
|
||||
$frac = str_pad( $frac, $exp, '0' );
|
||||
return new self( (int) ( $whole . $frac ), $currency );
|
||||
}
|
||||
|
||||
/** Format as the decimal string PayPal expects, e.g. 1099 USD -> "10.99". */
|
||||
public function toDecimalString(): string {
|
||||
$exp = self::exponent( $this->currency );
|
||||
if ( 0 === $exp ) {
|
||||
return (string) $this->minor;
|
||||
}
|
||||
$s = str_pad( (string) $this->minor, 3, '0', STR_PAD_LEFT );
|
||||
return substr( $s, 0, -2 ) . '.' . substr( $s, -2 );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace StreamGoals\Core;
|
||||
|
||||
/**
|
||||
* Boots feature modules. Each module exposes a static register() that adds its own hooks;
|
||||
* classes that do not exist yet are skipped so modules can land independently.
|
||||
*/
|
||||
final class Plugin {
|
||||
|
||||
private const MODULES = array(
|
||||
\StreamGoals\Rest\DonateController::class,
|
||||
\StreamGoals\Rest\WebhookController::class,
|
||||
\StreamGoals\Rest\OverlayController::class,
|
||||
\StreamGoals\Admin\Admin::class,
|
||||
);
|
||||
|
||||
public static function boot(): void {
|
||||
Installer::maybe_upgrade();
|
||||
foreach ( self::MODULES as $module ) {
|
||||
if ( class_exists( $module ) && method_exists( $module, 'register' ) ) {
|
||||
$module::register();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace StreamGoals\Core;
|
||||
|
||||
/**
|
||||
* Typed access to the single `sg_settings` option.
|
||||
*/
|
||||
final class Settings {
|
||||
|
||||
public static function defaults(): array {
|
||||
return array(
|
||||
'paypal_mode' => 'sandbox', // sandbox | live
|
||||
'paypal_client_id' => '',
|
||||
'paypal_client_secret' => '',
|
||||
'paypal_webhook_id' => '',
|
||||
'fee_percent_bps' => 349, // 3.49%
|
||||
'fee_fixed_minor' => 49,
|
||||
'anonymity_policy' => 'allow', // allow | require_name | force_anonymous
|
||||
'moderate_messages' => false,
|
||||
'profanity_filter' => true,
|
||||
'message_max_length' => 200,
|
||||
'min_donation_minor' => 100,
|
||||
'overlay_token' => '',
|
||||
);
|
||||
}
|
||||
|
||||
public static function all(): array {
|
||||
$saved = get_option( 'sg_settings', array() );
|
||||
return array_merge( self::defaults(), is_array( $saved ) ? $saved : array() );
|
||||
}
|
||||
|
||||
public static function get( string $key, mixed $default = null ): mixed {
|
||||
$all = self::all();
|
||||
return $all[ $key ] ?? $default;
|
||||
}
|
||||
|
||||
public static function update( array $values ): void {
|
||||
$allowed = array_intersect_key( $values, self::defaults() );
|
||||
update_option( 'sg_settings', array_merge( self::all(), $allowed ), false );
|
||||
}
|
||||
|
||||
public static function fee_calculator(): FeeCalculator {
|
||||
return new FeeCalculator( (int) self::get( 'fee_percent_bps' ), (int) self::get( 'fee_fixed_minor' ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit bootstrap="vendor/autoload.php" colors="true">
|
||||
<testsuites><testsuite name="unit"><directory>tests/Unit</directory></testsuite></testsuites>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: StreamGoals Donate
|
||||
* Description: PayPal donations with multiple goals, fee coverage, and OBS stream overlays (goal bar, subathon timer, counters, alerts).
|
||||
* Version: 0.1.0
|
||||
* Requires at least: 6.8
|
||||
* Requires PHP: 8.1
|
||||
* License: GPL-2.0-or-later
|
||||
* Text Domain: streamgoals-donate
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
define( 'STREAMGOALS_VERSION', '0.1.0' );
|
||||
define( 'STREAMGOALS_FILE', __FILE__ );
|
||||
define( 'STREAMGOALS_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'STREAMGOALS_URL', plugin_dir_url( __FILE__ ) );
|
||||
|
||||
// PSR-4 autoloader (no Composer needed at runtime, so the zip is drop-in installable).
|
||||
spl_autoload_register(
|
||||
static function ( string $class ): void {
|
||||
$prefix = 'StreamGoals\\';
|
||||
if ( strncmp( $class, $prefix, strlen( $prefix ) ) !== 0 ) {
|
||||
return;
|
||||
}
|
||||
$path = STREAMGOALS_DIR . 'includes/' . str_replace( '\\', '/', substr( $class, strlen( $prefix ) ) ) . '.php';
|
||||
if ( is_readable( $path ) ) {
|
||||
require $path;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
register_activation_hook( __FILE__, array( \StreamGoals\Core\Installer::class, 'activate' ) );
|
||||
add_action( 'plugins_loaded', array( \StreamGoals\Core\Plugin::class, 'boot' ) );
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace StreamGoals\Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use StreamGoals\Core\FeeCalculator;
|
||||
use StreamGoals\Core\Money;
|
||||
|
||||
final class FeeCalculatorTest extends TestCase {
|
||||
|
||||
public function test_ten_dollars_default_us_rates(): void {
|
||||
// (1000 + 49) / (1 - 0.0349) = 1086.9... -> 1087 (ceil), recipient nets >= 1000.
|
||||
$calc = new FeeCalculator( 349, 49 );
|
||||
$total = $calc->grossUp( 1000 );
|
||||
$this->assertSame( 1087, $total );
|
||||
$paypal_fee = (int) ceil( $total * 0.0349 ) + 49;
|
||||
$this->assertGreaterThanOrEqual( 1000, $total - $paypal_fee );
|
||||
}
|
||||
|
||||
public function test_zero_and_fee_for(): void {
|
||||
$calc = new FeeCalculator( 349, 49 );
|
||||
$this->assertSame( 0, $calc->grossUp( 0 ) );
|
||||
$this->assertSame( 87, $calc->feeFor( 1000 ) );
|
||||
}
|
||||
|
||||
public function test_rejects_bad_rates(): void {
|
||||
$this->expectException( \InvalidArgumentException::class );
|
||||
new FeeCalculator( 10000, 0 );
|
||||
}
|
||||
|
||||
public function test_money_parsing_and_format(): void {
|
||||
$this->assertSame( 1050, Money::fromDecimalString( '10.5', 'USD' )->minor );
|
||||
$this->assertSame( '10.99', ( new Money( 1099, 'USD' ) )->toDecimalString() );
|
||||
$this->assertSame( '5', ( new Money( 5, 'JPY' ) )->toDecimalString() );
|
||||
$this->assertSame( '0.05', ( new Money( 5, 'USD' ) )->toDecimalString() );
|
||||
}
|
||||
|
||||
public function test_money_rejects_garbage(): void {
|
||||
$this->expectException( \InvalidArgumentException::class );
|
||||
Money::fromDecimalString( '1e3', 'USD' );
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user