From 96f094bcb0b837928f7794eeac671ac402a71070 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 13 Oct 2026 18:11:05 +0200 Subject: [PATCH] docs(18): phase verification report (gaps_found: Stripe payment_failed rejects a retryable payment) --- .../18-payment-gateway/18-VERIFICATION.md | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 .planning/phases/18-payment-gateway/18-VERIFICATION.md diff --git a/.planning/phases/18-payment-gateway/18-VERIFICATION.md b/.planning/phases/18-payment-gateway/18-VERIFICATION.md new file mode 100644 index 0000000..0086bf7 --- /dev/null +++ b/.planning/phases/18-payment-gateway/18-VERIFICATION.md @@ -0,0 +1,308 @@ +--- +phase: 18-payment-gateway +verified: 2026-10-13T16:08:51Z +status: gaps_found +score: 10/11 must-haves verified +covered_files: + - .planning/phases/18-payment-gateway/18-01-PLAN.md + - .planning/phases/18-payment-gateway/18-01-SUMMARY.md + - .planning/phases/18-payment-gateway/18-02-PLAN.md + - .planning/phases/18-payment-gateway/18-02-SUMMARY.md + - .planning/phases/18-payment-gateway/18-03-PLAN.md + - .planning/phases/18-payment-gateway/18-03-SUMMARY.md + - .planning/phases/18-payment-gateway/18-04-PLAN.md + - .planning/phases/18-payment-gateway/18-04-SUMMARY.md + - .planning/phases/18-payment-gateway/18-05-PLAN.md + - .planning/phases/18-payment-gateway/18-05-SUMMARY.md + - .planning/phases/18-payment-gateway/18-06-PLAN.md + - .planning/phases/18-payment-gateway/18-06-SUMMARY.md + - .planning/phases/18-payment-gateway/18-07-PLAN.md + - .planning/phases/18-payment-gateway/18-07-SUMMARY.md + - admin/src/app/money.ts + - admin/src/components/form/PreviewField.vue + - admin/src/components/form/RecordActions.vue + - admin/src/components/form/fields/MoneyField.vue + - admin/src/components/form/fields/PasswordField.vue + - admin/src/components/form/formContext.ts + - admin/src/components/form/registry.ts + - admin/src/components/list/CellValue.vue + - admin/src/components/list/DataTable.vue + - admin/src/styles/main.css + - admin/src/views/PreviewView.vue + - admin/src/views/SettingsFormView.vue + - modules/cabana/admin_openapi.go + - modules/cabana/crud.go + - modules/cabana/field_dependent.go + - modules/cabana/field_money.go + - modules/cabana/form_schema.go + - modules/cabana/http.go + - modules/cabana/list_schema.go + - modules/cabana/registry.go + - modules/cabana/relation_child.go + - modules/cabana/relation_form.go + - modules/cabana/schema_types.go + - modules/cabana/settings.go + - modules/cabana/settings_secret.go + - modules/pact/capabilities.go + - scripts/check-phase18.sh +covered_digest: "v2:sha256:3f01e32ce16c66fd8767b087a9dd61f4d00e4c6d9f0d57ed0f45017f5b591bed" +sibling_heads: # outside the project root, so not in covered_digest; verified at these commits + sm-paymentgateway-plugin: f55320a + sm-pgstripe-plugin: d488834 + vue-paymentgateway-layer: ac2b960 (tag v0.1.0 is one test-only commit behind) + summercms.go: a591932 (framework tag v0.1.25 at 2f5e8e3, pushed) +behavior_unverified: 0 +overrides_applied: 0 +decision_coverage: + honored: 17 + total: 17 + not_honored: [] +gaps: + - truth: "SC-2 / goal 'take payments': the Stripe operator takes a payment reliably through the notification route; as on PHP, a failed card attempt never makes the payment unpayable" + status: failed + reason: "webhook.go maps payment_intent.payment_failed to Reject. Stripe sends that event on every declined attempt while the PaymentIntent returns to requires_payment_method and the customer may retry on the same Checkout page or client secret. Reject is final (Accept only from Created/Sent), so the later checkout.session.completed / payment_intent.succeeded finds Can(Accept)=false and operator.accept returns nil with no log (A-CR-02). The customer is charged; the order stays AwaitingPayment and the cron cancels it. Checkout is the default mode, the README tells operators to subscribe to payment_intent.payment_failed, and the event_table subtest asserts a requires_payment_method intent becomes Rejected, so the defect is the documented production behaviour. The PHP Stripe operator never rejected from a notification (processNotification only accepts when isPaidInStripe && can(accept))." + artifacts: + - path: "../sm-pgstripe-plugin/webhook.go" + issue: "lines 161-162: case EventPaymentIntentPaymentFailed -> o.reject (final state on a retryable signal)" + - path: "../sm-pgstripe-plugin/operator.go" + issue: "accept (about line 439): returns nil silently when Can(Accept) is false, without telling a replay (state Accepted) from a paid-at-Stripe payment in Rejected/Canceled" + - path: "../sm-pgstripe-plugin/host_test.go" + issue: "event_table (about line 905): asserts payment_intent.payment_failed with status requires_payment_method moves the payment to Rejected; the test encodes the defect" + - path: "../sm-pgstripe-plugin/README.md" + issue: "lines 33 and 62 document payment_failed: Reject and tell operators to subscribe to it" + missing: + - "Treat payment_intent.payment_failed as non-terminal: log it and leave the payment Sent (reject only on terminal signals such as checkout.session.async_payment_failed, and optionally checkout.session.expired / payment_intent.canceled)" + - "In accept, when Can(Accept) is false and the state is not Accepted, log at Error level with payment id, event id and intent id (and optionally fire an event for a manual refund)" + - "Change event_table to assert payment_failed leaves the payment Sent, and add a decline-then-succeed subtest to TestHostStripeFlow that ends Accepted / order Paid" + - "Update the README event table" +advisory: [] +human_verification: + - test: "Mail wording: send each of the 27 templates (PL unsuffixed, EN -en) through the memory or log driver, or read views/mail/**.htm in sm-paymentgateway-plugin" + expected: "Polish copy reads naturally and matches the PHP mails in meaning; variables (order code, totals, items, names, orderUrl) render; the bank transfer block is readable as plain lines" + why_human: "Language quality and tone cannot be checked by grep or tests" + - test: "Admin quote cart widget (Quotes > update a Quote order) in light and dark mode: add a quote item, edit description/qty/net/VAT, try 12.34567 and qty 0, remove a row, then process_quote" + expected: "Busy row while posting, refused change restores the row with a danger toast, totals update from the server, the placed quote renders read-only; colours follow --c-* in both themes" + why_human: "Visual rendering of a custom element inside the SPA; no browser test covers it" + - test: "Settings pages payment_gateway, payment_gateway_mailer and pgstripe in light and dark mode; on pgstripe set a secret, reload, remove it, undo" + expected: "Tabs and error chips render; secrets show only the stored/env/empty status line, never the value; long comments and Polish tab labels wrap" + why_human: "Visual check of SettingsFormView with the new tabs and write-only fields" + - test: "Run the vue-paymentgateway-layer .playground against a running instance; walk order page -> checkout -> redirect, AwaitingPayment polling, order list, narrow screen" + expected: "UI-SPEC L1-L8 layouts on --pg-* tokens; Pay redirects once; polling announces Paid or shows the slow copy" + why_human: "Visual layout; layer tests run without a DOM" + - test: "Horoskopia consumer parity: compare the acme.books fixture (sm-pgstripe-plugin/fixture_plugin_test.go) with horoscope BookApiController::purchase/pay and BookFulfilmentService::transitionToPaid, and decide whether the pgstripe operator will be enabled on Horoskopia after cutover" + expected: "The fixture makes the same calls; the Stripe operator is enabled on Horoskopia (or RecordExternal is changed), because Go guards Send/Accept with the operator's enabled setting while PHP's guard was always true for a directly constructed StripeOperator. With the operator disabled, the fixture-style webhook skips Send/Accept and answers 200, leaving the payment Created and the order Placed with no error (B-WR-04)" + why_human: "The real Horoskopia port is a later seed; whether its PHP settings have the Stripe operator enabled is production configuration, not code" + - test: "Review the judgment-tier prohibitions table below (non-authoritative LLM verdicts) and accept or reject each" + expected: "Every prohibition is accepted as held, or turned into a gap" + why_human: "Judgment-tier prohibitions need explicit human resolution in interactive verification" +--- + +# Phase 18: Payment Gateway Verification Report + +**Phase Goal:** `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let Horoskopia's horoscope plugin create orders, take payments and react to payment events, as it does on PHP. +**Verified:** 2026-10-13T16:08:51Z +**Status:** gaps_found +**Re-verification:** No, initial verification + +**Mode note:** ROADMAP marks Phase 18 `mode: mvp`, but `user-story.validate` returns `valid: false` because the goal is not written as "As a ..., I want to ..., so that ...". The orchestrator asked for verification, so this report uses standard goal-backward verification and adds a User Flow Coverage table derived from the Horoskopia flow. To make the mode consistent, either run `/msd mvp-phase 18` to restate the goal or drop `mode: mvp`. + +## User Flow Coverage (derived; the goal is not a user story) + +| Step | Expected | Evidence | Status | +|------|----------|----------|--------| +| Create order | Consumer builds Item/Cart/Details and calls `orders.Service.Make`, gets a Placed order with `hash_id` | `orders/service.go` Make; `fixture_plugin_test.go` MakeOrder; demo lists 6 orders | VERIFIED | +| Take payment, path (b) (Horoskopia today) | Own Checkout Session plus own webhook, then `RecordExternal`, `Send`, `Accept`; order Paid | `TestHostStripeFlow/consumer_driven_path_b` passed in this run | VERIFIED (with the operator enabled; see human item 5) | +| Take payment, path (a) (operator, D-16) | Operator Checkout/PaymentIntent, signed webhook, Accepted, Paid | `checkout_operator_path` and `payment_intent_operator_path` passed. A declined attempt followed by a successful retry ends Rejected with the money captured | FAILED (gap 1) | +| React to payment events | One `OrderPaid` event, paid mail after commit, replay is a no-op | `assertPaid` and `replay_and_race` in TestHostStripeFlow; TestMailTracer | VERIFIED | + +## Goal Achievement + +### Observable Truths + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | SC-1: the Payment machine (6 states) and Order machine (12 states, 19 transitions, all refund paths) port the PHP transitions, and money is exact decimal | ✓ VERIFIED | Diffed `orders/machine.go` and `payments/machine.go` line by line against PHP `core/Order.php` and `core/Operator.php` stateMachineConfig: from-sets, targets, initial and final states, the Ship guard (`isShippingEnabled`) and enabled guards (Cancel unguarded) all match. State strings match the PHP constants. `TestOrderMatrix` (228 pairs) and `TestPaymentMatrix` (30 pairs) check against an independent hand-copied PHP table, not the machine itself. Money is `Amount4` int64×10⁴ with an exact Scan/Value; the only `float64` in production is a display switch for JSON detail values (`controllers/orders_partials.go:382`), not money. TestMoneyVectors, TestAmount4 and TestRefundBridge passed in this run. Warnings: B-CR-02, B-WR-01 | +| 2 | SC-2: operators register at build time through a Go interface that replaces `core/Operator` and the `paymentgateway.booted` push | ✓ VERIFIED | `operators.HasOperators`; `boot.go` scans `party.Activated` and publishes `*operators.Registry`; a duplicate code fails (`ErrDuplicateCode`, TestOperatorRegistry); `sm-pgstripe-plugin/plugin.go` has `PaymentOperators()` and `Requires golem15.paymentgateway` | +| 3 | SC-2 / goal: Cash, manual transfer and Stripe operators are implemented, and the Stripe operator takes payments without losing one that PHP would have kept | ✗ FAILED | Cash, ManualTransfer and Disabled ship with the PHP codes. The Stripe operator works on the happy path, but `payment_intent.payment_failed` moves the payment to Rejected, a final state, on a retryable decline, and a later success is dropped silently (A-CR-01 with A-CR-02). See Gaps | +| 4 | SC-2: the `_paymentgateway/{operator}/{mode?}/{result?}` route keeps its PHP behaviour | ✓ VERIFIED | `routes.go`: 3 patterns × GET/POST/PUT/PATCH/DELETE under the notify throttle only; `payments/notification.go` caps the body at 1 MiB, runs `VerifyNotification` before `ExtractUUID`/`FindByUUID`, and only registered and enabled operators resolve. TestNotificationRoute, TestNotificationSecurity and TestNotificationRouteSmoke passed | +| 5 | SC-3: the Order/Payment events, their mail templates, the admin screens (orders, payments, settings) and the scheduled cancel/finish/update commands are ported, and contracts (tables, event names, config keys) stay non-breaking | ✓ VERIFIED | 24 events named with the namespace `Golem15\PaymentGateway\Events\` plus the PHP class names (set-equal to PHP `events/`). 55 `.htm` mail files (27 codes × PL/EN plus the layout). `schedule.go` has the same five commands and cadences as PHP `registerSchedule`; `pg:update-orders`/`pg:update-metadata` (unregistered in PHP) are documented non-ports. Table names equal the PHP `updates/` set plus the settings singletons (PHP `system_settings` keys are mapped by `import-winter`). The demo admin serves orders (localized state labels), payments, shippings, quotes, quote items and the three settings pages; pgstripe secrets show only `meta.secrets` states. Admin, mail and command suites passed on Postgres | +| 6 | SC-4: a host test proves the order-create, Stripe-pay and order-paid flow Horoskopia relies on | ✓ VERIFIED | `TestHostStripeFlow` (14 subtests: both pay paths, wrong secret, missing header or secret, older api_version, replay and race, browser return, refund) passed on testcontainers Postgres in this run. The happy-path flow is proven; the decline-retry defect is truth 3. Path (b) runs with the operator enabled (human item 5) | +| 7 | SC-5: the new code has unit tests, delivered in the last plan | ✓ VERIFIED | `PHASE18_REQUIRE_POSTGRES=1 bash scripts/check-phase18.sh --all` exited 0: self-test, go, plugins, admin (1377 vitest), layers (69 TAP, 0 fail), docs (docs checker, OpenAPI, dist match), postgres (9 + 22 + 3 suites). Framework `go vet ./...` and `go test -short ./...` are green (run once in this verification). The 72/74 PHP test map is recorded in 18-VALIDATION.md | +| 8 | D-07/D-13 framework: cabana `money` field and column (exact decimal string, 422 keys, no rounding), write-only `lagoon.Encrypted` settings secrets with env fallback, richeditor and tabs on settings, danger actions, preview Edit rule, table kit | ✓ VERIFIED | `field_money.go`, `settings_secret.go`, `registry.ts` maps `money` to MoneyField. TestMoneyField*, TestSettingsSecret*, TestSettingsTabs, TestRecordActionDanger and the Postgres smokes passed; v0.1.25 is tagged and pushed (ls-remote). Warning: A-CR-03 | +| 9 | D-11: v2 rows carry `"v": 2`; the permanent legacy JMS reader decodes golden fixtures; the typed extension registry replaces setCartClass/setDetailsClass | ✓ VERIFIED (with warning) | TestLegacyCartGoldens, TestLegacyFixtures and TestCartRegistry passed. Warning: `cart.RegisterDetailsParams` stores types that nothing reads (recorded in deferred-items.md). No PHP consumer in the reference trees uses setDetailsClass, so no stated criterion depends on it | +| 10 | D-06/D-17: the customer order API `/_paymentgateway/api/v1` (12 endpoints, owner / guest_code / guest_token authz, 404 for forbidden, `process` returns `{redirect_url}` or the requires_action bundle) and `vue-paymentgateway-layer` | ✓ VERIFIED (with warnings) | `api/*.go`; TestCustomerAPI, TestCustomerAPITracer and TestCustomerAPIAuthz passed; layer tag v0.1.0 pushed and 69 layer tests passed, including the checkout and orderPage backstops. Warnings: B-CR-01, A-WR-09, B-WR-03, B-WR-06 | +| 11 | D-08: settings pages and mail: the PG and Mailer settings pages, 27 templates under the PHP codes, four mailer groups sent after commit | ✓ VERIFIED | TestPGSettingsSmoke, TestMailerSettingsSmoke, TestMailTracer (rollback sends nothing) and TestMailersMatrixSmoke passed; TestMailCatalog checks every registered name has a file. Wording is a human item | + +**Score:** 10/11 truths verified (0 present but behavior-unverified). The behaviour-dependent truths (replay and race idempotency, after-commit mail, transaction rollback) are covered by named tests that passed in this run. + +### Required Artifacts + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `modules/cabana/field_money.go` | money field and column compile, validation | ✓ VERIFIED | wired via `compileMoneyKeys` in form_schema.go | +| `modules/cabana/settings_secret.go` | write-only secrets, SecretFallbacks | ✓ VERIFIED | used by pgstripe settings; demo shows `meta.secrets` | +| `admin/src/components/form/fields/MoneyField.vue`, `admin/src/app/money.ts` | decimal input, parse, format | ✓ VERIFIED (warning A-CR-03) | registered in registry.ts | +| `modules/pact/capabilities.go` | `AdminRecordAction.Danger` | ✓ VERIFIED | | +| `../sm-paymentgateway-plugin/money/{money,amount}.go` | exact money | ✓ VERIFIED | | +| `../sm-paymentgateway-plugin/fsm/fsm.go`, `orders/machine.go`, `payments/machine.go` | PHP machines | ✓ VERIFIED | equal to PHP | +| `../sm-paymentgateway-plugin/operators/operator.go`, `registry.go` | Operator, HasOperators, Registry | ✓ VERIFIED | | +| `../sm-paymentgateway-plugin/orders/service.go`, `orders/bridge.go` | services and bridge | ✓ VERIFIED (warning B-CR-02) | | +| `../sm-paymentgateway-plugin/payments/service.go` incl. `RecordExternal` | payments API | ✓ VERIFIED (warning B-WR-04) | | +| `../sm-paymentgateway-plugin/cart/legacy.go` | legacy JMS reader | ✓ VERIFIED | `RegisterDetailsParams` is hollow (warning) | +| `../sm-paymentgateway-plugin/routes.go` | notification route and API | ✓ VERIFIED | | +| `../sm-paymentgateway-plugin/controllers/*`, `assets/js/quote-cart.js`, `lang/*` | admin | ✓ VERIFIED | serves on the demo | +| `../sm-paymentgateway-plugin/mail.go`, `events/mailers.go`, `views/mail/**` | mail | ✓ VERIFIED | | +| `../sm-pgstripe-plugin/operator.go`, `webhook.go` | Stripe operator | ✗ DEFECT | exists and is wired; event mapping defect (gap 1) | +| `../sm-pgstripe-plugin/client.go`, `models/settings.go`, `console/import_winter.go` | client seam, settings, import | ✓ VERIFIED | | +| `../sm-pgstripe-plugin/host_test.go` | D-03/D-04 proof host | ✓ VERIFIED | encodes the gap-1 behaviour in event_table | +| `../vue-paymentgateway-layer/app/**` | layer | ✓ VERIFIED | | +| `scripts/check-phase18.sh` | fail-closed gate | ✓ VERIFIED | self-test passed; `--all` green with Postgres required | +| `.planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md` | decision note | ✓ VERIFIED | | + +### Key Link Verification + +| From | To | Via | Status | +|------|----|-----|--------| +| cabana form_schema.go | field_money.go | `compileMoneyKeys` | WIRED | +| admin registry.ts | MoneyField.vue | `['money', MoneyField]` | WIRED | +| paymentgateway boot.go | operators/registry.go | `party.Activated` scan for `HasOperators`, `app.Publish` | WIRED | +| orders/bridge.go | events | `app.Events.Listen` on the five payment events | WIRED | +| payments/notification.go | operator | `VerifyNotification` before `ExtractUUID`/`FindByUUID` | WIRED | +| pgstripe plugin.go | paymentgateway registry | `PaymentOperators()` | WIRED | +| pgstripe operator.go | webhook.go | `ValidatePayload` on the raw body | WIRED | +| host_test.go | notification route | signed POST to `/_paymentgateway/Z29sZW0x...` | WIRED | +| routes.go | api/*.go | `r.Group(APIPrefix, throttle)` | WIRED | +| layer useOrder.ts | client.ts | `createPaymentGatewayClient` | WIRED | +| events/mailers.go | postcard | `postcard.Message` via `lagoon.AfterCommit` | WIRED | + +### Data-Flow Trace (Level 4) + +| Artifact | Data | Source | Real data | Status | +|----------|------|--------|-----------|--------| +| Orders/Payments admin lists | rows, money columns | GORM over `golem15_paymentgateway_*` | demo returns 6 orders and 4 payments with exact `"151.4300"` strings | ✓ FLOWING | +| pgstripe settings GET | secret states | `settings_secret.go` projection | `meta.secrets` states, no values | ✓ FLOWING | +| Stripe amount | `unit_amount` | `TotalGross().Minor()` from the stored cart | 4900 asserted in the host test | ✓ FLOWING | + +### Behavioral Spot-Checks + +| Behavior | Command | Result | Status | +|----------|---------|--------|--------| +| Full phase gate with Postgres | `PHASE18_REQUIRE_POSTGRES=1 bash scripts/check-phase18.sh --all` | all 7 stages PASS, exit 0 | ✓ PASS | +| Framework regressions | `go vet ./... && go test -short -count=1 ./...` | exit 0 | ✓ PASS | +| Admin screens live | demo `/backend/api/v1/golem15/paymentgateway/{orders,payments,shippings,quotes,quoteables}`, `/settings/{payment_gateway,payment_gateway_mailer,pgstripe}` (GET only) | real rows; localized states; secrets redacted | ✓ PASS | +| Decision coverage | `check.decision-coverage-verify` | 17/17 honored | ✓ PASS | + +### Probe Execution + +Not applicable: no probe is declared in the phase and none exists under `scripts/*/tests/probe-*.sh`. + +### Requirements Coverage + +REQUIREMENTS.md maps no IDs to Phase 18 (the roadmap says "Requirements: TBD"). Coverage is by the roadmap SCs and CONTEXT decisions: + +| ID | Status | Evidence | +|----|--------|----------| +| SC-1 | ✓ SATISFIED | truth 1 | +| SC-2 | ✗ BLOCKED (partial) | truths 2 and 4 verified; truth 3 failed | +| SC-3 | ✓ SATISFIED | truth 5 | +| SC-4 | ✓ SATISFIED | truth 6 | +| SC-5 | ✓ SATISFIED | truth 7 | +| D-01..D-17 | ✓ 17/17 honored | decision-coverage gate | + +No orphaned requirements. + +### Test Quality Audit + +| Test | Linked | Skipped | Circular | Assertion level | Verdict | +|------|--------|---------|----------|-----------------|---------| +| TestOrderMatrix, TestPaymentMatrix | SC-1 | 0 | no (independent PHP table) | value | OK | +| TestMoneyVectors | SC-1 | 0 | no (PHP MoneyRight vectors) | value | OK | +| TestHostStripeFlow | SC-4 | 0 (the gate fails on skip) | no | behavioral | OK, but `event_table` asserts the defective payment_failed → Rejected mapping | +| TestCustomerAPI* | D-06 | 0 | no | behavioral | OK | +| layer checkout/orderPage | D-06/D-17 | 0 | no | behavioral | OK | + +Disabled tests on requirements: 0. Circular patterns: 0. The only `t.Skip` calls are the `-short` guards in the testcontainers TestMain. + +### Anti-Patterns Found + +| File | Line | Pattern | Severity | Impact | +|------|------|---------|----------|--------| +| (phase files) | - | TBD/FIXME/XXX | none found | - | +| `../sm-paymentgateway-plugin/cart/registry.go` | 112 | `RegisterDetailsParams` stores types that nothing reads | ⚠️ Warning | exported, documented API with no effect | +| `../sm-pgstripe-plugin/webhook.go` | 161 | retryable failure mapped to a final state | 🛑 Blocker | gap 1 | +| `../sm-paymentgateway-plugin/orders/bridge.go` | 98-110 | `successful_payment_id` written before `Can(Pay)` | ⚠️ Warning | B-CR-02 | +| `../sm-paymentgateway-plugin/currency/fixer.go` | 50, 66-69 | access key in the query string and logged inside `*url.Error` | ⚠️ Warning | B-CR-04 | + +### Code Review Critical Findings Against the Criteria + +The review was advisory and run after the push. Each critical finding was checked in code and weighed against the stated criteria: + +| Finding | Confirmed in code | Breaks a stated criterion? | Classification | +|---------|-------------------|----------------------------|----------------| +| A-CR-01 payment_failed rejects | yes (`webhook.go:161`; the test asserts it; the README tells operators to subscribe) | **Yes**: the Stripe operator does not "take payments" reliably, and the change is new relative to PHP, whose Stripe notification never rejected | 🛑 Blocker (gap 1) | +| A-CR-02 paid signal on Rejected/Canceled dropped silently | yes (`operator.go` accept) | Not on its own: PHP processNotification was equally silent. It turns A-CR-01 from visible into silent loss, so it belongs in the same fix | grouped with gap 1 | +| B-CR-02 bridge persists successful_payment_id before Can(Pay) | yes (`bridge.go:98-110`); PHP only assigned it in memory | Not for the proven flows (normal Pay, replay). It diverges from PHP when a second payment is accepted on a Paid/Canceled/Finished order, where refund paths would then target the wrong payment | ⚠️ Warning (fix recommended; small) | +| B-CR-01 forgeable guest codes on legacy orders | yes (`LegacySalt` and the deterministic `guest_code`) | No SC covers the customer API; PHP had the same weakness. It is a real exposure on a new public API (read/cancel/refund-request of legacy guest orders) | ⚠️ Warning, security; decide before any app with legacy guest orders enables the API | +| A-CR-03 comma parsed as a decimal in `en` | yes (`money.ts` parseMoneyInput is locale-blind) | Not an SC; an admin data-entry hazard (1000× error) | ⚠️ Warning | +| B-CR-03 default_currency editable | yes (README admits "does not convert stored amounts") | Not an SC; PHP locked it via the wizard | ⚠️ Warning | +| B-CR-04 Fixer key in logs | yes | Not an SC; violates the spirit of the secrets prohibitions | ⚠️ Warning, security | + +Other confirmed warnings relevant to the Horoskopia contract: +- **B-WR-04**: `RecordExternal` says "need not be enabled", but Send/Accept are guarded by the operator's enabled setting. PHP's guard was effectively always true for Horoskopia's directly constructed `StripeOperator`, which "bypasses PaymentFactory enabled-check". If Horoskopia keeps the Stripe operator disabled, path (b) stalls silently (human item 5). +- **Release hygiene (info):** both plugin repositories have no tags; `sm-pgstripe-plugin` requires `sm-paymentgateway-plugin v0.0.0` through a local replace, and both `go.mod` files require `summercms v0.1.24` although they rely on v0.1.25 changes (21c5618 settings dropdown provider, eb60c32 money trailing zeros). This works through the submodule plus `go.work` layout, but an app must itself require v0.1.25 or newer. + +### Prohibitions (judgment tier, non-authoritative LLM verdicts) + +| Plan | Prohibition (short) | Verdict | Evidence | +|------|--------------------|---------|----------| +| 01 | framework never names a consuming app or carries payment-specific types | held | grep over the phase-18 framework files is clean | +| 01, 02 | no float for money | held | grep; the one `float64` is a JSON display switch | +| 01, 05 | secrets never returned or logged | held for the settings secrets (demo GET shows only states) | B-CR-04 leaks the Fixer key, which is outside the literal scope | +| 01, 06, 07 | nothing pushed or tagged without go-ahead | not verifiable from code | recorded go-aheads are in the SUMMARYs | +| 02 | legacy rows never bulk-rewritten | held | no migration or command rewrites cart/details; TestLegacyFixtures | +| 02, 05 | unsigned input never changes state; verify before lookup | held | notification.go ordering; browser return re-checks at Stripe | +| 02, 04 | PHP names not renamed | held | events, tables, commands and state strings compared | +| 02 | redactor on every logged body | held | TestRedactor; dashed api-key fix | +| 03 | partials are curated view models; state changes only through services | held (spot check) | TestOrdersAdminSmoke | +| 04 | mail only after commit | held | TestMailTracer rollback case | +| 05 | amount computed server-side | held | 4900 asserted | +| 05, 07 | no network in tests | held | grep finds no external hosts in `_test.go` | +| 06 | API never accepts amount, currency or state | held (spot check) | TestCustomerAPIAuthz | +| 06 | guest code grants exactly one order | held literally; weak in strength (B-CR-01) | | +| 07 | no test weakened to go green | flagged | `event_table` asserts the defective mapping; written to the plan's must_have, not weakened | + +### Human Verification Required + +These are listed now so they are not lost; they become actionable once gap 1 is closed. + +1. **Mail wording (PL/EN)**: read or send the 27 templates. Expected: natural Polish, PHP meaning kept, variables render. Why human: language quality. +2. **Quote cart widget, light and dark**: add, edit, refuse, remove, process. Expected: busy row, restore with a toast, read-only after placing. Why human: visual rendering of a custom element. +3. **Settings pages, light and dark** (payment_gateway, mailer, pgstripe secrets). Expected: tabs, chips, status lines without values, wrapping. Why human: visual. +4. **Layer playground**: order → checkout redirect → polling → list. Expected: UI-SPEC L1-L8. Why human: visual; no DOM in tests. +5. **Horoskopia consumer parity and the operator enabled flag**: compare the acme.books fixture with BookApiController and BookFulfilmentService, and confirm the Stripe operator will be enabled on Horoskopia (B-WR-04). Why human: production configuration and a later port. +6. **Prohibition verdicts**: accept or reject the table above. + +### Gaps Summary + +One root cause blocks the goal: the Stripe operator's webhook mapping treats `payment_intent.payment_failed` as terminal. Stripe sends that event on every declined attempt and lets the customer retry, so in the default Checkout mode a single decline followed by a successful retry leaves the customer charged while the payment stays Rejected, and the success event is dropped without a log line (A-CR-01 with A-CR-02). The PHP Stripe operator never rejected a payment from a notification, so this is a regression in "take payments as on PHP", not a carried-over quirk. The fix is small and local to `sm-pgstripe-plugin`: log instead of rejecting on payment_failed, log at Error level for a paid signal on a non-Accepted final payment, flip the `event_table` assertion, add a decline-then-succeed subtest, and update the README. It needs a pgstripe patch commit. No framework re-tag is needed. + +Everything else holds: the PHP state machines are reproduced exactly, money is exact, build-time operator registration, the notification route, events, mail, admin, commands and the customer API work, and the full gate (including Postgres) and the framework suite are green. The other confirmed critical findings (B-CR-02, B-CR-01, A-CR-03, B-CR-03, B-CR-04) do not break a stated success criterion, but B-CR-02 (a one-block reorder) and B-CR-04 are cheap to fix in the same gap-closure round, and B-CR-01 should be decided before any app with legacy guest orders exposes the customer API. + +**If the developer judges the operator path (a) outside the Horoskopia contract** (Horoskopia uses path (b) today), the gap can be accepted with an override. This is not recommended, because D-16 exists so the Horoskopia port can drop its own Checkout code: + +```yaml +overrides: + - must_have: "SC-2 / goal 'take payments': the Stripe operator takes a payment reliably through the notification route" + reason: "" + accepted_by: "" + accepted_at: "" +``` + +### Decision Coverage + +All trackable CONTEXT.md decisions are honored by shipped artifacts (17/17). + +--- + +_Verified: 2026-10-13T16:08:51Z_ +_Verifier: Claude (msd-verifier)_