Scaffold plugin: architecture contract, schema, settings, fee math

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-15 08:42:30 -08:00
commit 939193f258
12 files changed
+2081

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
vendor/
node_modules/
*.zip
.phpunit.result.cache
.DS_Store
dist/
+11
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+56
View File
@@ -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.
+34
View File
@@ -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;
}
}
+99
View File
@@ -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 );
}
}
+48
View File
@@ -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 );
}
}
+27
View File
@@ -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();
}
}
}
}
+46
View File
@@ -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' ) );
}
}
+4
View File
@@ -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>
+34
View File
@@ -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' ) );
+43
View File
@@ -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' );
}
}