docs(18): create gap-closure plan 18-08 (Stripe payment_failed is non-terminal)
This commit is contained in:
@@ -1113,7 +1113,7 @@ Plans:
|
||||
4. A host test proves the order-create, Stripe-pay and order-paid flow that Horoskopia relies on.
|
||||
5. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 7/7 plans executed
|
||||
**Plans:** 7/8 plans executed
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
@@ -1137,6 +1137,9 @@ Plans:
|
||||
**Wave 7** *(blocked on 18-01..18-06)*
|
||||
- [x] 18-07-PLAN.md — Unit tests last: the 74-case PHP map, money and FSM matrices, legacy fixtures, security evidence, framework and layer tests, `scripts/check-phase18.sh`, validated 18-VALIDATION.md, final push approval
|
||||
|
||||
**Wave 8** *(gap closure; blocked on 18-05 and 18-07)*
|
||||
- [ ] 18-08-PLAN.md — Gap closure SC-2 (sm-pgstripe-plugin): `payment_intent.payment_failed` logged and non-terminal so a declined-then-retried payment is accepted; a paid signal on a payment that refuses Accept is logged at Error level (replays silent); expired and canceled stay no-ops by decision; decline-then-succeed proofs in both modes, Docker-free decline coverage, README event table; push with go-ahead, no tag
|
||||
|
||||
### Phase 18.1: Payment Gateway Subscriptions (INSERTED)
|
||||
|
||||
**Goal:** The generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS. Horoskopia's subscription flow (plans, checkout, Stripe webhook, `golem15.pgsub.*` events, cancel and portal) works as it does on PHP.
|
||||
|
||||
@@ -3,16 +3,16 @@ msd_state_version: "1.0"
|
||||
milestone: v1.0
|
||||
current_phase: 18
|
||||
current_phase_name: Payment Gateway
|
||||
status: verifying
|
||||
status: executing
|
||||
stopped_at: Completed 18-07-PLAN.md
|
||||
last_updated: "2026-10-13T15:47:20.826Z"
|
||||
last_updated: "2026-10-13T16:43:35.617Z"
|
||||
last_activity: 2026-10-13
|
||||
last_activity_desc: Phase 18 execution started
|
||||
state_head: 8d33af4f22338285248b483a88176b35bbe4e5b5
|
||||
state_head: 96f094bcb0b837928f7794eeac671ac402a71070
|
||||
progress:
|
||||
total_phases: 40
|
||||
completed_phases: 14
|
||||
total_plans: 175
|
||||
total_plans: 176
|
||||
completed_plans: 175
|
||||
gsd_state_version: "1.0"
|
||||
milestone_name: milestone
|
||||
@@ -29,9 +29,9 @@ See: .planning/PROJECT.md (updated 2026-10-12)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 18 (Payment Gateway) — EXECUTING
|
||||
Phase: 18 (Payment Gateway) — READY TO EXECUTE
|
||||
Plan: 7 of 7
|
||||
Status: Phase complete — ready for verification
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-10-13 — Phase 18 execution started
|
||||
|
||||
Progress: [████░░░░░░] 44%
|
||||
|
||||
419
.planning/phases/18-payment-gateway/18-08-PLAN.md
Normal file
419
.planning/phases/18-payment-gateway/18-08-PLAN.md
Normal file
@@ -0,0 +1,419 @@
|
||||
---
|
||||
phase: 18-payment-gateway
|
||||
plan: 08
|
||||
type: execute
|
||||
wave: 8
|
||||
depends_on: ["18-05", "18-07"]
|
||||
files_modified:
|
||||
- ../sm-pgstripe-plugin/webhook.go
|
||||
- ../sm-pgstripe-plugin/operator.go
|
||||
- ../sm-pgstripe-plugin/browser_return.go
|
||||
- ../sm-pgstripe-plugin/host_test.go
|
||||
- ../sm-pgstripe-plugin/operator_test.go
|
||||
- ../sm-pgstripe-plugin/webhook_test.go
|
||||
- ../sm-pgstripe-plugin/README.md
|
||||
autonomous: false
|
||||
gap_closure: true
|
||||
requirements: [SC-2, SC-4, D-14, D-16]
|
||||
estimate:
|
||||
tokens: 120000
|
||||
raw_tokens: 120000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-2 gap truth, missing item 1 (review A-CR-01): a signed `payment_intent.payment_failed` event for a Sent Stripe payment answers 200 and changes nothing: the payment stays Sent, the order stays AwaitingPayment, no PaymentRejected event fires, no mail is sent and the rows' updated_at do not move; the operator logs one Warn line carrying the payment id, the payment uuid, the event id, the PaymentIntent id and, when Stripe sent them, the `last_payment_error` code and decline code, never the decline message or the body."
|
||||
- "SC-2, missing item 3 (decline then succeed): after that decline, a signed `checkout.session.completed` with `payment_status` paid (Checkout mode) or a signed `payment_intent.succeeded` (PaymentIntent mode) accepts the payment, and the order is Paid with `successful_payment_id`, one OrderPaid event and one paid mail; a late redelivery of the decline event after acceptance changes nothing (TestHostStripeFlow/decline_then_succeed and /decline_then_succeed_payment_intent)."
|
||||
- "SC-2, missing item 1 (terminal signals only): `checkout.session.async_payment_failed` still rejects, because the asynchronous payment of a completed session cannot be retried on it; `checkout.session.expired` and `payment_intent.canceled` answer 200 and leave the payment Sent, as on PHP, so the gateway's `pg:cancel-sent-payments` closes an abandoned payment after `payment_sent_to_canceled_timeout`; TestHostStripeFlow/event_table asserts all three, and its `payment_failed` case with a `requires_payment_method` intent asserts Sent."
|
||||
- "SC-2, missing item 2 (review A-CR-02): when Stripe reports a payment paid (an accepting event or the browser return) and the payment's state refuses Accept although it was never accepted (Rejected, Canceled, or a Created or Sent payment whose operator guard refuses), the operator changes nothing, answers as before (200 for an event, 303 for the browser return) and logs one Error line carrying the payment id, uuid, state, the event id or `browser_return`, the PaymentIntent id and the stored Checkout Session id; a paid signal for an Accepted or Refunded payment (a replay, or the success URL reloaded after a refund) logs no Error line and changes nothing."
|
||||
- "The payment machine, the payment gateway plugin and the purchase-time status mapping are unchanged: Accept stays possible only from Created or Sent, the Error path adds no transition, payment event, data write or automatic refund, and `sm-paymentgateway-plugin` stays at f55320a with a clean tree."
|
||||
- "Missing item 4: the README's webhook event list and its Stripe Dashboard step document the new mapping (the decline logged, async_payment_failed rejects, expired and canceled change nothing, the Error line for a paid payment that cannot be accepted) and list the new exported `PaymentError`."
|
||||
- "Every commit keeps `go vet ./...` and `go test -short ./...` green in sm-pgstripe-plugin; TestHostStripeFlow passes with 17 subtests on Postgres, TestStripeOperator and TestStripeWebhook pass Docker-free, and the gate stages `plugins` and `postgres` (PHASE18_REQUIRE_POSTGRES=1) pass; the fix is committed in sm-pgstripe-plugin only, nothing is pushed before the human's reply and nothing is tagged."
|
||||
artifacts:
|
||||
- path: "../sm-pgstripe-plugin/webhook.go"
|
||||
provides: "the non-terminal decline branch and the PaymentError decode of last_payment_error"
|
||||
contains: "last_payment_error"
|
||||
- path: "../sm-pgstripe-plugin/operator.go"
|
||||
provides: "accept telling a replay apart from a paid payment whose state refuses Accept"
|
||||
contains: "refuses Accept"
|
||||
- path: "../sm-pgstripe-plugin/browser_return.go"
|
||||
provides: "the browser return passing its trigger to accept"
|
||||
contains: "browserReturnTrigger"
|
||||
- path: "../sm-pgstripe-plugin/host_test.go"
|
||||
provides: "the decline-then-succeed proofs in both modes and the paid-after-final-state proof"
|
||||
contains: "decline_then_succeed"
|
||||
- path: "../sm-pgstripe-plugin/operator_test.go"
|
||||
provides: "Docker-free proof that a decline never reaches the payment service and logs no message text"
|
||||
contains: "a declined attempt is logged and never rejected"
|
||||
- path: "../sm-pgstripe-plugin/README.md"
|
||||
provides: "the corrected webhook event table and Dashboard events"
|
||||
contains: "pg:cancel-sent-payments"
|
||||
key_links:
|
||||
- from: "../sm-pgstripe-plugin/webhook.go"
|
||||
to: "../sm-pgstripe-plugin/operator.go"
|
||||
via: "handleEvent passes the Stripe event id to accept as its trigger"
|
||||
pattern: "o.accept\\(ctx, pc, ev.ID"
|
||||
- from: "../sm-pgstripe-plugin/browser_return.go"
|
||||
to: "../sm-pgstripe-plugin/operator.go"
|
||||
via: "browserReturn passes browserReturnTrigger to accept"
|
||||
pattern: "browserReturnTrigger"
|
||||
- from: "../sm-pgstripe-plugin/host_test.go"
|
||||
to: "../sm-paymentgateway-plugin/payments/notification.go"
|
||||
via: "signed POSTs of the decline and the retry to /_paymentgateway/<base64 Stripe operator code>"
|
||||
pattern: "EventPaymentIntentPaymentFailed"
|
||||
- from: "../sm-pgstripe-plugin/operator.go"
|
||||
to: "../sm-paymentgateway-plugin/payments/machine.go"
|
||||
via: "svc.Can(Accept) decides; the payment state picks the silent or the Error branch"
|
||||
pattern: "PaymentStateRefunded"
|
||||
prohibitions:
|
||||
- statement: "No test is weakened or skipped: the event_table decline case is changed to assert the corrected outcome, and every other existing subtest keeps its assertions."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Logs never carry the webhook body, the decline message, the customer's e-mail or key material; only ids, the payment state and Stripe's codes."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed before the human's reply at the checkpoint, and this plan creates no framework or plugin tag."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The payment gateway plugin and its payment machine are not changed."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal: `Golem15.PaymentGateway` and its Stripe operator run on SummerCMS as shared plugins whose contracts let a consuming shop plugin create orders, take payments and react to payment events, as it does on PHP.
|
||||
|
||||
This plan's slice (gap closure 18-08, wave 8, after 18-05 built the Stripe operator and 18-07 its tests): close the one failed truth of `18-VERIFICATION.md`, "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". All code changes are in the sibling repository `../sm-pgstripe-plugin` (HEAD d488834 at planning time).
|
||||
|
||||
## Gap being closed (18-VERIFICATION.md `gaps:`)
|
||||
|
||||
- `webhook.go` lines 161-162 map `payment_intent.payment_failed` to Reject, a final state, although Stripe sends that event on every declined attempt and lets the customer retry on the same Checkout page or client secret. The later success finds Can(Accept) false and is dropped (review A-CR-01).
|
||||
- `operator.go` accept (about line 439) returns nil silently when Can(Accept) is false, without telling a replay (Accepted) apart from a paid-at-Stripe payment in Rejected or Canceled (review A-CR-02).
|
||||
- `host_test.go` event_table (about line 905) asserts the defect.
|
||||
- `README.md` lines 33 and 62 document the decline as Reject and tell operators to subscribe to it.
|
||||
|
||||
The PHP operator never moved a payment from a notification except to Accept (`Stripe::processNotification` accepts only when `isPaidInStripe && can(accept)`), so this is a regression of the port, not a carried-over quirk.
|
||||
|
||||
## Decisions taken in this plan (recorded for the verifier and the SUMMARY)
|
||||
|
||||
1. **The decline is not terminal.** `payment_intent.payment_failed` logs a Warn line and leaves the payment Sent (per the verification gap; RESEARCH Pattern 6 listed Reject for it as optional, which this plan supersedes).
|
||||
2. **`checkout.session.async_payment_failed` keeps rejecting.** It is terminal: the session is complete and its asynchronous payment failed, so the customer cannot retry on it.
|
||||
3. **`checkout.session.expired` and `payment_intent.canceled` do not reject (and do not cancel).** The current event table already answers them 200 without a change. The PHP operator never changed a payment from a notification except to Accept. The gateway's own `pg:cancel-sent-payments` (PHP parity) closes the abandoned Sent payment after `payment_sent_to_canceled_timeout`. A notification-driven terminal transition would first need the event object id matched against the stored session or intent id (review A-WR-02, A-IN-02), the same class of mistake as this gap. The behaviour stays, is documented in the handleEvent doc comment and the README, and is asserted in event_table. No new event constants. COVERAGE.md records both events as OPT-OUT with this reason (edited at plan time).
|
||||
4. **The Error branch covers every state that was never accepted.** In accept, when Can(Accept) is false, Accepted and Refunded are silent: the payment was accepted once, and a Checkout Session still reports paid after a refund, so a reload of the success URL after a refund would otherwise raise a false alarm. Every other state (Rejected, Canceled, or Created or Sent with the operator guard refusing) logs at Error level. This follows the "i.e." of the user decision (Rejected and Canceled) and adds Refunded to the silent side, for the reason just given.
|
||||
5. **No event, no automatic refund, no data write on the Error path.** A new payment event would be a new public contract on a shared core plugin with no listener. An automatic refund moves money and needs its own decision. Writing `is_paid_in_stripe` on a final-state row would change data the admin and the bridge read. The Error log line is the alert hook. The rest of A-CR-02 (expiring the Checkout Session from a PaymentCanceled listener, an admin notice) and A-IN-02 stay open review findings and are listed in the SUMMARY.
|
||||
6. **The purchase-time status mapping is unchanged.** A server-confirmed decline at Purchase (Details parameter `payment_method_id`) still answers StatusError and the service applies Send plus Reject. The response carries no client secret, so the customer cannot retry that intent and starts a new payment (PHP parity).
|
||||
7. **Release.** Commits only, staged by path in sm-pgstripe-plugin, no co-author tags. Pushing is the Task 4 checkpoint. No tag: neither plugin repository has tags, and the first plugin tag is a separate release decision. No app pointer bump: at planning time no application mounts `sm-pgstripe-plugin` (no `.gitmodules`, `go.mod` or `go.work` outside the plugin names it). The executor re-checks this at Task 4.
|
||||
|
||||
<assumption_delta_decision>
|
||||
- Detector: `detected: true`, one `pluralization` signal on "fallback", taken from the ROADMAP's plan-05 line (D-13, env fallback for the Stripe secrets).
|
||||
- Primary noun: the payment, one `golem15_paymentgateway_payments` row per Stripe Checkout Session or PaymentIntent, found through the `payment_uuid` metadata.
|
||||
- Decision: no-change. The signal belongs to plan 05's settings decision. This plan lets one payment see several card attempts, but the attempts live at Stripe, and the payment's identity and state machine do not change.
|
||||
</assumption_delta_decision>
|
||||
|
||||
## Source audit (gap closure)
|
||||
|
||||
| Source | ID | Item | Task | Status |
|
||||
|--------|----|------|------|--------|
|
||||
| GOAL | SC-2 | the Stripe operator takes payments as on PHP: a failed attempt never makes the payment unpayable | 1, 2, 3 | COVERED |
|
||||
| GAP | missing 1 | payment_failed non-terminal and logged; reject only on terminal signals; expired and canceled decided | 1 (mapping), 2 (expired and canceled recorded and asserted) | COVERED |
|
||||
| GAP | missing 2 | accept logs at Error level for a paid payment that refuses Accept; event decided (none) | 2 | COVERED |
|
||||
| GAP | missing 3 | event_table asserts Sent; decline-then-succeed ends Accepted and Paid | 1 (Checkout), 3 (PaymentIntent) | COVERED |
|
||||
| GAP | missing 4 | README event table | 3 | COVERED |
|
||||
| REQ | (none mapped) | phase_req_ids is null; the gap truth SC-2 stands in | all | COVERED |
|
||||
| RESEARCH | Pattern 6 | event table; the optional Reject on the decline is superseded by decision 1 | 1 | COVERED |
|
||||
| RESEARCH | Security V7 | never log raw webhook bodies with PII | 1, 3 (PaymentError does not decode the message) | COVERED |
|
||||
| CONTEXT | D-14 | verification before lookup, unchanged; notification handling closer to PHP | 1, 2 | COVERED |
|
||||
| CONTEXT | D-16 | Checkout mode accepts completed and async_payment_succeeded; decline retries now work in that mode | 1, 2 | COVERED |
|
||||
| CONTEXT | D-01..D-13, D-15, D-17 | not touched by this gap | - | not affected |
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `../sm-pgstripe-plugin/webhook.go`: exported type `PaymentError` (`Code`, `DeclineCode`, `Type`) and field `EventObject.LastPaymentError`; unexported `(*Operator).attemptFailed`; the decline is handled in its own case; the three accept calls pass `ev.ID`.
|
||||
- `../sm-pgstripe-plugin/operator.go`: `accept(ctx, pc, trigger, extra)`; unexported `(*Operator).refusedAccept`; Error log message containing `refuses Accept`.
|
||||
- `../sm-pgstripe-plugin/browser_return.go`: unexported constant `browserReturnTrigger` (`browser_return`).
|
||||
- `../sm-pgstripe-plugin/host_test.go`: subtests `decline_then_succeed`, `paid_after_final_state`, `decline_then_succeed_payment_intent`; event_table asserts the corrected decline, expired and canceled outcomes; `logCapture` keeps level and attributes (`capturedLog`, `find`).
|
||||
- `../sm-pgstripe-plugin/operator_test.go`: `recordHandler`; TestStripeOperator subtests `a declined attempt is logged and never rejected`, `expired sessions and canceled intents change nothing`.
|
||||
- `../sm-pgstripe-plugin/webhook_test.go`: TestStripeWebhook subtest `last_payment_error decodes its codes`.
|
||||
- `../sm-pgstripe-plugin/README.md`: the corrected event list, the Dashboard events, `PaymentError` in the API reference, the Testing notes.
|
||||
- `.planning/phases/18-payment-gateway/COVERAGE.md` (edited at plan time): the decline row reason, `checkout.session.expired` re-decided, a new `payment_intent.canceled` OPT-OUT row.
|
||||
- Commits in sm-pgstripe-plugin: `fix(18-08)` (Task 1), `fix(18-08)` (Task 2), `test(18-08)` and `docs(18-08)` (Task 3); a push only after the Task 4 reply.
|
||||
|
||||
<objective>
|
||||
Close the phase-18 verification gap: a declined Stripe card attempt leaves the payment open so the customer's retry is accepted and the order is paid. A paid signal for a payment that can no longer be accepted is logged at Error level instead of being dropped. The tests assert the corrected behaviour and the README documents it (SC-2, SC-4, D-14, D-16).
|
||||
|
||||
Purpose: SC-2 says the Stripe operator takes payments as PHP did. With the current mapping, one decline followed by a successful retry leaves the customer charged and the order unpaid, and nothing in the logs says so. The default Checkout mode makes this the documented production behaviour.
|
||||
Output: three code commits and one docs commit in `../sm-pgstripe-plugin`, a 17-subtest TestHostStripeFlow, the Docker-free decline and decode coverage, a green gate (`plugins`, `postgres`), and the human's push decision.
|
||||
|
||||
This is the phase's last plan, and it carries its own unit tests (user decision for this gap closure). The project rule "unit tests are the last plan" therefore holds.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/18-payment-gateway/18-CONTEXT.md
|
||||
@.planning/phases/18-payment-gateway/18-VERIFICATION.md
|
||||
@.planning/phases/18-payment-gateway/18-05-SUMMARY.md
|
||||
@.planning/phases/18-payment-gateway/18-07-SUMMARY.md
|
||||
|
||||
The executor's working directory is `summercms.go`. Every plugin command runs as `go -C ../sm-pgstripe-plugin …` or `git -C ../sm-pgstripe-plugin …`, and the gate runs as `bash scripts/check-phase18.sh <stage>` from `summercms.go`. Stage files by explicit path and never use `git add -A`. No co-author tags. Planning docs (the SUMMARY) and plugin code go in separate commits. Nothing is pushed or tagged before the Task 4 reply. The Docker-backed host test skips under `-short`. If `docker info` is refused although the user is in the docker group, run the Docker commands through `sudo -u golem` (CLAUDE.md). For this gap closure a NOT RUN host test means the gap is still open, and the executor stops and reports it at Task 4. No test reaches the network: every Stripe client points at the httptest fake.
|
||||
|
||||
<interfaces>
|
||||
Current code at d488834 (read before changing):
|
||||
- `webhook.go`: event constants `EventCheckoutSessionCompleted`, `EventCheckoutSessionAsyncPaymentSucceeded`, `EventCheckoutSessionAsyncPaymentFailed`, `EventPaymentIntentSucceeded`, `EventPaymentIntentPaymentFailed` (:16-22); `MinimalEvent{ID, Type, APIVersion, Data{Object EventObject}}` (:51-58); `EventObject{ID, Object, Metadata, ClientReferenceID, PaymentStatus, PaymentIntent ExpandableID, LatestCharge ExpandableID, Status}` (:62-71); `func (o *Operator) handleEvent(ctx context.Context, pc *operators.PaymentContext, ev *MinimalEvent) error` (:142-165), which logs "webhook event" at Info with event, type and payment first, then checks `ownsPayment(pc)`.
|
||||
- `operator.go`: `type Operator struct{ app *backpack.App; logger *slog.Logger }`; `NewOperator` takes `slog.Default()` when the plugin's `PaymentOperators()` runs during activation (`plugin.go:70`); `func (o *Operator) accept(ctx context.Context, pc *operators.PaymentContext, extra map[string]string) error` (:434-456); `reject` (:460-471); `o.payments()` errors with "the payment gateway's payment service is not published" when no `*payments.Service` is published; data keys `DataCheckoutSessionID`, `DataPaymentIntentID`, `DataChargeID`, `DataIsPaidInStripe`; `pc.Payment.DataString(key)`.
|
||||
- `browser_return.go`: `browserReturn` calls accept at :66 when `paidAtStripe` reports paid.
|
||||
- Gateway (unchanged, read-only): `payments.Service` methods `Can`, `Accept`, `Cancel`, `Reject`, `Refund`, `SaveData` (`payments/service.go`); states `pgmodels.PaymentStateCreated|Sent|Accepted|Rejected|Canceled|Refunded` with the values `Created`, `Sent`, `Accepted`, `Rejected`, `Canceled`, `Refunded` (`models/payment.go:12-17`); Accept from Created or Sent only (`payments/machine.go`); `events.PaymentRejected` (`events/events.go:139`); the notification route dispatches to HandleNotification whatever the payment's state, under the row lock (`payments/notification.go`).
|
||||
- `host_test.go` (package `pgstripe_test`): `bootHost`, `configure(t, checkoutMode)`, `countPaid`, `purchase(t, params)` returning user, order, payment, response; `event(t, id, typ, apiVersion, object)`, `signed`, `postEvent(payload, secret)`, `notify(method, target, body, signature)`, `completedEvent(t, id, uuid, apiVersion)` (object `cs_test_1`, payment_intent `pi_test_1`); `snapshot` and `assertPaid`; `logCapture` with `lines()` and `has(part)` storing messages only (:967-1008); `captureLogs(t)` must run before `bootHost`; constants `testWebhookSecret`, `stripeNotifyPath`, `fixtureReturnURL`, `fixturePluginID`; the fake numbers sessions and intents from one sequence (the first purchase in a fresh host gets `cs_test_1` with `pi_test_1`), `env.fake.setSession(id, key, value)`; TestHostStripeFlow has 14 subtests at d488834.
|
||||
- `operator_test.go` (package `pgstripe`, Docker-free): `operatorApp(t, environ...)`, `paymentContext(t, params)` (payment ID 7, UUID `11111111-2222-4333-8444-555555555555`, Operator `OperatorCode`, empty State), TestStripeOperator builds `&Operator{app: operatorApp(t), logger: ...}`.
|
||||
- `webhook_test.go`: TestStripeWebhook (external package) with DecodeEvent subtests.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a declined attempt leaves the Checkout payment open, and the retry's signed completed event accepts it and pays the order end to end on the proof host</name>
|
||||
<reversibility rating="reversible">One switch case and a decode field in webhook.go; no stored data, route or event changes, and no application mounts the plugin yet.</reversibility>
|
||||
<precondition>`git -C ../sm-pgstripe-plugin status --porcelain` prints nothing, `git -C ../sm-pgstripe-plugin merge-base --is-ancestor d488834 HEAD` exits 0, and `docker info` answers (directly or through `sudo -u golem`).</precondition>
|
||||
<files>../sm-pgstripe-plugin/webhook.go, ../sm-pgstripe-plugin/host_test.go</files>
|
||||
<read_first>../sm-pgstripe-plugin/webhook.go (whole file), ../sm-pgstripe-plugin/operator.go (:395-471 HandleNotification, accept, reject), ../sm-pgstripe-plugin/host_test.go (:240-436 helpers, :437-596 the first three subtests, :879-932 event_table, :957-1008 completedEvent and logCapture), ../sm-pgstripe-plugin/stripefake_test.go (:93-153 the session sequence), ../sm-paymentgateway-plugin/payments/machine.go (the from-sets), .planning/phases/18-payment-gateway/18-VERIFICATION.md (`gaps:`), .planning/phases/18-payment-gateway/18-REVIEW.md (A-CR-01, :153-164), /media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe/operators/Stripe.php (processNotification, accept-only)</read_first>
|
||||
<action>Per SC-2 and D-16, gap 1 missing items 1 and 3 (review A-CR-01). This makes the decline non-terminal per the verification gap (RESEARCH Pattern 6 listed Reject for this event as optional).
|
||||
|
||||
webhook.go: add the exported type `PaymentError` with the string fields `Code` (json `code`), `DeclineCode` (json `decline_code`) and `Type` (json `type`). The decline message field is deliberately not decoded, so no code path can log it. Add the field `LastPaymentError *PaymentError` with json key `last_payment_error` to `EventObject`. In `handleEvent`, give `EventPaymentIntentPaymentFailed` its own case. That case calls a new unexported method `(o *Operator) attemptFailed(pc *operators.PaymentContext, ev *MinimalEvent)` and returns nil. It must not call the reject helper or anything on the payment service. `attemptFailed` logs at Warn level the message `golem15.pgstripe: payment attempt failed; the payment stays open for a retry` with the attributes `payment` (pc.Payment.ID), `uuid` (pc.Payment.UUID), `event` (ev.ID) and `intent` (ev.Data.Object.ID). It adds `code` and `decline_code` from `LastPaymentError` only when they are non-empty. The `EventCheckoutSessionAsyncPaymentFailed` case keeps rejecting, unchanged. Rewrite the decline bullet of the handleEvent doc comment so it explains the change: Stripe sends this event on every declined attempt; the PaymentIntent returns to requires_payment_method and the customer may retry on the same Checkout page or client secret; Reject is final in the payment machine, so a later success would be lost; and PHP processNotification never moved a payment except to Accept. Correct the comment above the event constants if it still says the decline rejects.
|
||||
|
||||
host_test.go: in `event_table`, the `intentFailing` case (event `evt_pi_fail`, status requires_payment_method) now expects `pgmodels.PaymentStateSent` and the order still AwaitingPayment. Update its error message to match. Add the subtest `decline_then_succeed` directly after `payment_intent_confirmed_and_declined`:
|
||||
- `logs := captureLogs(t)` before `bootHost` (the operator takes slog.Default during activation); `configure` with `stripemodels.CheckoutModeCheckout`; `paid := env.countPaid(t)`; a `*events.PaymentRejected` listener under `fixturePluginID` that counts deliveries in an `atomic.Int32`.
|
||||
- `purchase(t, nil)`: the payment is Sent, with session `cs_test_1` and intent `pi_test_1`. Take `before := env.snapshot(...)`.
|
||||
- Post a signed `pgstripe.EventPaymentIntentPaymentFailed` event `evt_decline_1` whose object is the intent: `id` pi_test_1, `object` payment_intent, `status` requires_payment_method, `metadata.payment_uuid` the payment uuid, and `last_payment_error` with type card_error, code card_declined, decline_code generic_decline and the message `Your card was declined.`. Expect 200 with `"received":true`, a snapshot equal to `before` (states, updated_at, OrderPaid count, mail count), zero PaymentRejected deliveries, and `logs.has("payment attempt failed")`.
|
||||
- Post `completedEvent(t, "evt_retry_paid", p.UUID, "")` and call `env.assertPaid(t, user, order, p, paid)`.
|
||||
- Post the same decline payload again (a late, out-of-order delivery): expect 200 and a snapshot equal to the one taken after `assertPaid`.
|
||||
|
||||
Commit in sm-pgstripe-plugin: stage `webhook.go` and `host_test.go` by path, subject `fix(18-08): payment_intent.payment_failed leaves the payment open for a retry (task 1)`, no co-author tag.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./...</automated>
|
||||
<fails_when>Non-zero exit: vet reports a problem, or a Docker-free test fails (for example, the changed EventObject decode broke TestStripeWebhook or TestStripeAuthentication).</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$/^(decline_then_succeed|event_table)$' -v . 2>&1 | grep -cE '^ --- PASS: TestHostStripeFlow/(decline_then_succeed|event_table) ' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon answers (rerun through sudo -u golem; the gap stays open while this is NOT RUN), or fewer than two PASS lines: the decline still moved the payment, a PaymentRejected or mail fired, the retry did not pay the order, the late decline changed something, or event_table still expects Rejected.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$' -v . 2>&1 | grep -c '^ --- PASS: TestHostStripeFlow/' | grep -qx 15</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon, or a PASS count other than 15 (the 14 subtests at d488834 plus decline_then_succeed): another pay path regressed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"last_payment_error"' ../sm-pgstripe-plugin/webhook.go` prints one line and `grep -n 'type PaymentError struct' ../sm-pgstripe-plugin/webhook.go` prints one line.
|
||||
- `! grep -A4 'case EventPaymentIntentPaymentFailed:' ../sm-pgstripe-plugin/webhook.go | grep -q 'o.reject('` exits 0, and `grep -c 'o.reject(' ../sm-pgstripe-plugin/webhook.go` prints 1 (only the async failure rejects).
|
||||
- `! grep -n 'json:"message"' ../sm-pgstripe-plugin/webhook.go | grep -q .` exits 0.
|
||||
- `grep -n 't.Run("decline_then_succeed"' ../sm-pgstripe-plugin/host_test.go` prints one line.
|
||||
- `git -C ../sm-pgstripe-plugin log -1 --format=%s` starts with `fix(18-08):` and `git -C ../sm-pgstripe-plugin log -1 --format=%B | grep -ci 'co-authored'` prints 0.
|
||||
</acceptance_criteria>
|
||||
<done>On the real stack with a fake Stripe, a declined Checkout attempt leaves the payment open, and the customer's successful retry accepts it, pays the order, fires OrderPaid once and sends the paid mail.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: accept tells a replay apart from a paid payment whose state refuses Accept and logs the second at Error level; expired and canceled stay no-ops by decision; the paid-after-final-state proof</name>
|
||||
<reversibility rating="reversible">A log line, a doc comment and an unexported parameter; no transition, payment event or stored field is added.</reversibility>
|
||||
<files>../sm-pgstripe-plugin/operator.go, ../sm-pgstripe-plugin/browser_return.go, ../sm-pgstripe-plugin/webhook.go, ../sm-pgstripe-plugin/host_test.go</files>
|
||||
<read_first>../sm-pgstripe-plugin/operator.go (:395-477 HandleNotification, accept, reject, ownsPayment), ../sm-pgstripe-plugin/browser_return.go (whole file), ../sm-pgstripe-plugin/webhook.go (handleEvent after Task 1), ../sm-pgstripe-plugin/host_test.go (:695-766 missing_header and missing_secret using logs.has, :817-932 browser_return, refund and event_table, :967-1008 logCapture), ../sm-pgstripe-plugin/stripefake_test.go (:111-153 createSession, getSession, setSession), ../sm-paymentgateway-plugin/payments/service.go (Can, Cancel, Refund), ../sm-paymentgateway-plugin/payments/notification.go (:100-140 the locked dispatch), ../sm-paymentgateway-plugin/console/pg_cancel_sent_payments.go (the timeout sweep the decision relies on), .planning/phases/18-payment-gateway/18-REVIEW.md (A-CR-02 :166-182, A-IN-02 :280-284)</read_first>
|
||||
<behavior>
|
||||
- Canceled payment, signed completed paid event: 200, the payment stays Canceled, the order is not Paid, no OrderPaid; one Error record `refuses Accept` with payment, uuid, state Canceled, event evt_late_paid, intent pi_test_1 and the stored session id.
|
||||
- Rejected payment, signed payment_intent.succeeded: 200, it stays Rejected; one Error record with state Rejected, event evt_late_pi and intent pi_late_2.
|
||||
- Browser return on the Canceled payment with Stripe reporting the session paid: 303 to the stored return URL, still Canceled; one Error record with event browser_return.
|
||||
- Accepted payment replayed, then refunded and replayed: every answer 200 and no Error record names it.
|
||||
- checkout.session.expired and payment_intent.canceled for a Sent payment: 200, the payment stays Sent.
|
||||
</behavior>
|
||||
<action>Per SC-2, D-14 and D-16, gap 1 missing item 2 (review A-CR-02), with decisions 3, 4 and 5 of this plan.
|
||||
|
||||
operator.go: change accept to `accept(ctx context.Context, pc *operators.PaymentContext, trigger string, extra map[string]string) error`. The trigger is the Stripe event id for a webhook event, or `browserReturnTrigger` for the browser return. Keep the `svc.Can(ctx, pc.Tx, pc.Payment, pgmodels.PaymentTransitionAccept)` call and return its error. When Can is false, call a new unexported method `(o *Operator) refusedAccept(pc *operators.PaymentContext, trigger string, extra map[string]string)` and return nil. refusedAccept:
|
||||
- For `pgmodels.PaymentStateAccepted` and `pgmodels.PaymentStateRefunded` it writes no log line. The payment was accepted once: a replayed or concurrent event, or a success URL reloaded after a refund, where the Checkout Session still reports paid.
|
||||
- For every other state it logs at Error level the message `golem15.pgstripe: Stripe reports the payment paid but its state refuses Accept; refund it at Stripe or settle the order by hand` with the attributes `payment` (pc.Payment.ID), `uuid`, `state` (pc.Payment.State), `event` (the trigger), `intent` (extra[DataPaymentIntentID], else the stored `DataPaymentIntentID`) and `session` (the stored `DataCheckoutSessionID`).
|
||||
- It changes nothing: no SaveData, no transition, no payment event, no Stripe call (decision 5).
|
||||
|
||||
Rewrite accept's doc comment to describe the two branches and the reason Refunded is silent.
|
||||
|
||||
browser_return.go: add the unexported constant `browserReturnTrigger = "browser_return"`, with a comment saying it is the trigger attribute of the browser return in accept's log, and pass it in browserReturn's accept call.
|
||||
|
||||
webhook.go: pass `ev.ID` as the trigger in the three accept calls (completed with paid, async_payment_succeeded, payment_intent.succeeded). Add to the handleEvent doc comment that `checkout.session.expired` and `payment_intent.canceled` fall through to the 200 no-op on purpose, for three reasons: PHP never changed a payment from a notification except to Accept; a terminal transition would first need the event object id matched against the stored session or intent id (review A-IN-02); and the gateway's `pg:cancel-sent-payments` closes the abandoned Sent payment after `payment_sent_to_canceled_timeout`. Add no new event constants.
|
||||
|
||||
host_test.go:
|
||||
- Extend `logCapture` to keep, per record, a new `capturedLog{Level slog.Level; Message string; Attrs map[string]string}`, with the attributes collected through `r.Attrs` and the values through `a.Value.String()`. Keep `lines()` and `has(part)` working on the messages so missing_header and missing_secret stay unchanged. Add `find(level slog.Level, part string) []capturedLog`.
|
||||
- In `event_table`, add two cases, each on a fresh purchase: a signed `checkout.session.expired` (object id the payment's stored session id, `status` expired, `payment_status` unpaid, `metadata.payment_uuid`) and a signed `payment_intent.canceled` (object id the stored intent id, `status` canceled, `metadata.payment_uuid`). Each answers 200 and leaves the payment Sent.
|
||||
- Add the subtest `paid_after_final_state` after `event_table`: `captureLogs(t)` before `bootHost`, Checkout mode, `countPaid`, then these four parts in order:
|
||||
1. Purchase p1, cancel it with `env.payments.Cancel` inside `env.tx`, and post `completedEvent(t, "evt_late_paid", p1.UUID, "")`. Expect 200, p1 still Canceled, its order not Paid, an OrderPaid count of 0, and exactly one `find(slog.LevelError, "refuses Accept")` record with payment = p1's id, uuid = p1.UUID, state = Canceled, event = evt_late_paid, intent = pi_test_1 and session = p1's stored session id.
|
||||
2. Purchase p2, post a signed `checkout.session.async_payment_failed` for its session (p2 becomes Rejected), then a signed `payment_intent.succeeded` `evt_late_pi` with object id `pi_late_2` and p2's uuid in its metadata. Expect 200, p2 still Rejected, and one Error record for p2 with state Rejected, event evt_late_pi and intent pi_late_2.
|
||||
3. Call `env.fake.setSession` on p1's session (`payment_status` paid, `status` complete) and GET the browser return for p1 (`stripeNotifyPath` with `pgUuid` = base64 of p1.UUID). Expect 303 to `fixtureReturnURL`, p1 still Canceled, and one Error record for p1 with event `browser_return`.
|
||||
4. Purchase p3 and post `completedEvent(t, "evt_ok_3", p3.UUID, "")`; p3 is Accepted. Post the same payload again, run `env.payments.Refund` inside `env.tx` (Refunded), then post the payload once more. Every answer is 200, and no Error record has payment = p3's id.
|
||||
After the four parts, the Error records containing `refuses Accept` number exactly three.
|
||||
|
||||
Commit in sm-pgstripe-plugin: stage `operator.go`, `browser_return.go`, `webhook.go` and `host_test.go` by path, subject `fix(18-08): log a paid Stripe payment whose state refuses Accept at Error level (task 2)`, no co-author tag.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./...</automated>
|
||||
<fails_when>Non-zero exit: vet reports a problem (for example, an accept call site still passes three arguments), or a Docker-free test fails.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$/^(paid_after_final_state|event_table|decline_then_succeed|missing_header|missing_secret|browser_return|replay_and_race)$' -v . 2>&1 | grep -cE '^ --- PASS: TestHostStripeFlow/(paid_after_final_state|event_table|decline_then_succeed|missing_header|missing_secret|browser_return|replay_and_race) ' | grep -qx 7</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon, or fewer than seven PASS lines: the Error line is missing or lacks an attribute, a replay or refunded payment logged at Error level, a final-state payment changed, expired or canceled moved the payment, or the logCapture change broke the existing log assertions.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$' -v . 2>&1 | grep -c '^ --- PASS: TestHostStripeFlow/' | grep -qx 16</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon, or a PASS count other than 16 (15 after Task 1 plus paid_after_final_state).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'refuses Accept' ../sm-pgstripe-plugin/operator.go` prints at least one line and `grep -n 'PaymentStateRefunded' ../sm-pgstripe-plugin/operator.go` prints at least one line.
|
||||
- `grep -c 'o.accept(ctx, pc, ev.ID' ../sm-pgstripe-plugin/webhook.go` prints 3 and `grep -c 'browserReturnTrigger' ../sm-pgstripe-plugin/browser_return.go` prints at least 2.
|
||||
- `grep -n 'checkout.session.expired' ../sm-pgstripe-plugin/webhook.go ../sm-pgstripe-plugin/host_test.go` prints at least two lines and `grep -n 'pg:cancel-sent-payments' ../sm-pgstripe-plugin/webhook.go` prints at least one line.
|
||||
- `grep -n 't.Run("paid_after_final_state"' ../sm-pgstripe-plugin/host_test.go` prints one line.
|
||||
- `test -z "$(git -C ../sm-paymentgateway-plugin status --porcelain)"` and `test "$(git -C ../sm-paymentgateway-plugin rev-parse --short HEAD)" = f55320a` both exit 0 (the gateway and its payment machine are untouched).
|
||||
</acceptance_criteria>
|
||||
<done>A customer charged by Stripe for a payment that can no longer be accepted shows up as one Error log line with every id needed to refund it, while replays stay silent, and the expired and canceled events are documented and proven no-ops.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Docker-free decline and decode coverage, the PaymentIntent-mode decline-then-succeed proof, the README event table, and the phase gate</name>
|
||||
<reversibility rating="reversible">Tests and documentation.</reversibility>
|
||||
<files>../sm-pgstripe-plugin/operator_test.go, ../sm-pgstripe-plugin/webhook_test.go, ../sm-pgstripe-plugin/host_test.go, ../sm-pgstripe-plugin/README.md</files>
|
||||
<read_first>../sm-pgstripe-plugin/operator_test.go (:1-131 helpers and the TestStripeOperator header), ../sm-pgstripe-plugin/webhook_test.go (:1-117 TestStripeWebhook), ../sm-pgstripe-plugin/host_test.go (:516-560 payment_intent_operator_path, the Task 1 decline_then_succeed), ../sm-pgstripe-plugin/stripefake_test.go (:155-215 createIntent and setIntent), ../sm-pgstripe-plugin/README.md (whole file: :20-40 Features, :57-71 Stripe Dashboard, :73-83 API reference, :141-175 Testing), ../sm-pgstripe-plugin/webhook.go and operator.go after Task 2, scripts/check-phase18.sh (:60-90 the required names, :600-680 the plugins and postgres stages)</read_first>
|
||||
<action>Per SC-2, SC-4 and D-16, gap 1 missing items 3 and 4, and the unit tests of this gap closure.
|
||||
|
||||
operator_test.go (package pgstripe, Docker-free): add a recording `slog.Handler` named `recordHandler`. It has a mutex and keeps, per record, the level, the message and the attributes as strings; `WithAttrs` and `WithGroup` return the receiver. Add two subtests to `TestStripeOperator`:
|
||||
- `a declined attempt is logged and never rejected`:
|
||||
- Build an `Operator` with `app: operatorApp(t)` and `logger: slog.New(rec)`. No payment service is published, so any transition attempt would fail with the service-not-published error.
|
||||
- Take `pc := paymentContext(t, nil)` and set `pc.Payment.State = pgmodels.PaymentStateSent`.
|
||||
- Decode with `DecodeEvent` a `payment_intent.payment_failed` body: id evt_dec, object id pi_dec, status requires_payment_method, `metadata.payment_uuid` the context's uuid, and `last_payment_error` with type card_error, code card_declined, decline_code insufficient_funds and the message `Your card has insufficient funds.`.
|
||||
- `op.handleEvent(ctx, pc, ev)` returns nil and the state is still Sent.
|
||||
- Exactly one Warn record contains `payment attempt failed`, with payment 7, uuid, event evt_dec, intent pi_dec, code card_declined and decline_code insufficient_funds.
|
||||
- No record's message or attribute value contains `Your card has`.
|
||||
- A second body without `last_payment_error` logs the Warn record with neither a `code` nor a `decline_code` attribute.
|
||||
- `expired sessions and canceled intents change nothing`: for `checkout.session.expired` and `payment_intent.canceled` bodies naming the same payment, handleEvent returns nil, the state stays Sent, and no Warn or Error record is written.
|
||||
|
||||
webhook_test.go: add the subtest `last_payment_error decodes its codes` to `TestStripeWebhook`. `DecodeEvent` of a `payment_intent.payment_failed` body yields a `Data.Object.LastPaymentError` with Code card_declined, DeclineCode generic_decline and Type card_error. A body without the field, and a body with the field set to JSON null, both decode to a nil `LastPaymentError`.
|
||||
|
||||
host_test.go: add the subtest `decline_then_succeed_payment_intent` directly after `decline_then_succeed`:
|
||||
- Configure with `stripemodels.CheckoutModePaymentIntent`, `countPaid`, then purchase: RequiresAction, intent pi_test_1, payment Sent.
|
||||
- A signed decline `evt_pi_decline` for pi_test_1 (status requires_payment_method, `last_payment_error` code card_declined, the payment's uuid in its metadata) answers 200 and leaves the payment Sent and the order AwaitingPayment.
|
||||
- A signed `payment_intent.succeeded` `evt_pi_retry` for pi_test_1 with `latest_charge` ch_retry_1 and the uuid answers 200. Then `assertPaid`, and the stored `charge_id` is ch_retry_1.
|
||||
|
||||
README.md (missing item 4, no consuming application named):
|
||||
- In the Features bullet on PaymentIntent mode, after the sentence about a decline failing the payment (Sent and Rejected), say that this is the server-confirmed decline at Purchase (`payment_method_id`). That response carries no client secret, so the customer retries with a new payment. A decline reported later by a webhook never rejects.
|
||||
- In the Webhook events bullet, replace the line that maps the decline event to Reject so that the list reads:
|
||||
- `checkout.session.completed` paid: Accept (unpaid: nothing).
|
||||
- `checkout.session.async_payment_succeeded`: Accept.
|
||||
- `checkout.session.async_payment_failed`: Reject. It is terminal: the asynchronous payment of a completed session failed.
|
||||
- `payment_intent.succeeded`: Accept, recording the charge.
|
||||
- `payment_intent.payment_failed`: no change; one Warn log line with the payment id, uuid, event id, PaymentIntent id and Stripe's decline codes. Stripe sends it on every declined attempt, and the customer may retry on the same Checkout page or card form.
|
||||
- `checkout.session.expired` and `payment_intent.canceled`: no change. The gateway's `pg:cancel-sent-payments` cancels the abandoned payment after `payment_sent_to_canceled_timeout`, as on PHP.
|
||||
- Any other type: 200 without a change.
|
||||
- After that list, add one sentence: a paid signal (event or browser return) for a payment that can no longer be accepted (Rejected or Canceled) changes nothing and writes one Error log line containing `refuses Accept`, with the payment id, uuid, state, event id or `browser_return`, PaymentIntent id and session id. The customer was charged, so refund at Stripe or settle the order by hand. A paid signal for an Accepted or Refunded payment is a silent no-op.
|
||||
- In the Stripe Dashboard step 2, list the events `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed` and `payment_intent.succeeded`, and mark `payment_intent.payment_failed` as optional (logged only).
|
||||
- Add `PaymentError` to the Webhook line of the API reference.
|
||||
- In Testing, extend the TestStripeOperator sentence with the decline log without a transition, and the TestStripeWebhook sentence with the `last_payment_error` codes. Extend the TestHostStripeFlow coverage sentence with "a declined attempt followed by a successful retry in both modes and a paid signal for a Canceled or Rejected payment".
|
||||
|
||||
Commits in sm-pgstripe-plugin, no co-author tags:
|
||||
- `test(18-08): Docker-free decline and decode coverage and the PaymentIntent decline-then-succeed proof (task 3)`, staging operator_test.go, webhook_test.go and host_test.go by path.
|
||||
- `docs(18-08): README event table for retryable declines and the refused-accept log (task 3)`, staging README.md.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short -run '^(TestStripeOperator|TestStripeWebhook)$' -v . 2>&1 | grep -cE '^ --- PASS: (TestStripeOperator/a_declined_attempt_is_logged_and_never_rejected|TestStripeOperator/expired_sessions_and_canceled_intents_change_nothing|TestStripeWebhook/last_payment_error_decodes_its_codes) ' | grep -qx 3 && bash scripts/check-phase18.sh plugins</automated>
|
||||
<fails_when>Non-zero exit: vet fails; fewer than three PASS lines for the new Docker-free subtests (the decline reached the payment service, the Warn line lacks an attribute or carries the message text, expired or canceled wrote a Warn or Error record, or the decode lost a code); or the gate's plugins stage reports a required test missing, skipped or failing.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-pgstripe-plugin test -count=1 -run '^TestHostStripeFlow$' -v . 2>&1 | grep -c '^ --- PASS: TestHostStripeFlow/' | grep -qx 17 && PHASE18_REQUIRE_POSTGRES=1 bash scripts/check-phase18.sh postgres</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (rerun through sudo -u golem; NOT RUN leaves the gap open), a PASS count other than 17 (16 after Task 2 plus decline_then_succeed_payment_intent), or the gate's postgres stage reports a Postgres suite in any of the three Go repositories missing, skipped or failing.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `! grep -n 'payment_intent.payment_failed`: Reject' ../sm-pgstripe-plugin/README.md | grep -q .` exits 0 (the async failure line `checkout.session.async_payment_failed`, which keeps Reject, does not match this pattern).
|
||||
- `grep -n 'pg:cancel-sent-payments' ../sm-pgstripe-plugin/README.md`, `grep -n 'refuses Accept' ../sm-pgstripe-plugin/README.md` and `grep -n 'PaymentError' ../sm-pgstripe-plugin/README.md` each print at least one line, and `grep -c 'payment_intent.payment_failed' ../sm-pgstripe-plugin/README.md` prints at least 2.
|
||||
- `grep -n 't.Run("decline_then_succeed_payment_intent"' ../sm-pgstripe-plugin/host_test.go` prints one line and `grep -n 'a declined attempt is logged and never rejected' ../sm-pgstripe-plugin/operator_test.go` prints one line.
|
||||
- `! grep -rniE "horoskopia|fonoteka|grzyby" ../sm-pgstripe-plugin --include='*.go' --include='*.md' | grep -q .` exits 0.
|
||||
- `git -C ../sm-pgstripe-plugin log -2 --format=%s` prints a `docs(18-08):` subject followed by a `test(18-08):` subject, and `git -C ../sm-pgstripe-plugin status --porcelain` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>The decline and decode rules have Docker-free tests, both pay modes prove decline-then-succeed on Postgres, the README tells operators the true event mapping, and the phase gate is green.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 4: Push approval for the sm-pgstripe-plugin fix commits (no tag)</name>
|
||||
<read_first>this plan's Task 1 to 3 SUMMARY notes, .planning/phases/18-payment-gateway/18-07-SUMMARY.md (:320-335 the pushed heads, the push-all precedent), .planning/phases/18-payment-gateway/18-REVIEW-DISPOSITION.md (open high and critical findings)</read_first>
|
||||
<action>Do everything that can be automated, print the release sheet, then stop.
|
||||
- In sm-pgstripe-plugin: `git status --porcelain` prints nothing, then `git fetch origin`. `git log --oneline HEAD..origin/main` must print nothing; otherwise stop and report, and never merge or rebase on your own. `git log --oneline origin/main..HEAD` lists exactly this plan's four commits on top of d488834.
|
||||
- Re-check that no application mounts the plugin: search `../*/.gitmodules`, `../*/go.mod` and `../*/go.work` for `sm-pgstripe-plugin`, ignoring the plugin's own files. A hit means an app pointer bump commit in that app is a separate follow-up, listed on the sheet and not done here.
|
||||
|
||||
The sheet lists:
|
||||
- the four commits;
|
||||
- the TestHostStripeFlow subtest count (17) and the gate stages run (`plugins`, `postgres`);
|
||||
- decisions 3 to 5 of this plan (expired and canceled stay no-ops, the Error branch, no event or automatic refund);
|
||||
- that no tag is created (neither plugin repository is tagged; the first plugin tag is a separate release decision);
|
||||
- the review findings still open for this operator (A-CR-02's Checkout Session expiry on cancel, A-IN-02, A-WR-01 to A-WR-05).
|
||||
|
||||
Stop for the human.</action>
|
||||
<decision>Push the four sm-pgstripe-plugin commits of this gap closure to origin/main now?</decision>
|
||||
<context>The pushed commits are public history. The framework needs no re-tag (18-VERIFICATION), and no application consumes the plugin yet, so pushing affects no deployed app. Holding keeps the fix local, and the phase cannot be verified against pushed code.</context>
|
||||
<options>
|
||||
<option id="push">
|
||||
<name>Push sm-pgstripe-plugin main (no tag)</name>
|
||||
<pros>The fix is published, and `/msd-verify-work 18` re-runs against the pushed head.</pros>
|
||||
<cons>None beyond the usual: a pushed commit is public history.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold</name>
|
||||
<pros>Nothing leaves the machine until the human has read the sheet.</pros>
|
||||
<cons>The verifier sees an unpushed head; a later session must push it.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "push" or "hold". The executor pushes only after "push" (`git -C ../sm-pgstripe-plugin push origin main`) and records the pushed head in the SUMMARY.</resume-signal>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase18.sh plugins && test -z "$(git -C ../sm-pgstripe-plugin status --porcelain)" && echo gap-gate-ok</automated>
|
||||
<fails_when>Non-zero exit, or "gap-gate-ok" absent from the output: the plugins stage is red or the plugin tree has uncommitted changes. Never present the sheet in that state.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The verify printed gap-gate-ok and the SUMMARY records the human's reply verbatim.
|
||||
- After "push": `git -C ../sm-pgstripe-plugin rev-parse HEAD` equals `git -C ../sm-pgstripe-plugin rev-parse origin/main`. After "hold": `git -C ../sm-pgstripe-plugin log --oneline origin/main..HEAD` lists this plan's four commits and nothing was pushed.
|
||||
- `git -C ../sm-pgstripe-plugin tag` prints nothing in either case.
|
||||
</acceptance_criteria>
|
||||
<done>The human has decided whether the gap-closure commits are published, and the SUMMARY records what was pushed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Stripe → notification route | signed events; the signature is verified before any lookup (T-18-28, unchanged); this plan changes what a verified event may do to the payment |
|
||||
| browser → return URL with pgUuid | unauthenticated; the return re-checks at Stripe (T-18-30, unchanged); it now passes its own trigger to accept |
|
||||
| operator → application log | the new Warn and Error lines cross into log storage that operators and log shippers read |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-18-49 | Tampering | handleEvent: a retryable decline finalizing the payment, so the customer's later real payment is lost | high | mitigate | the decline case only logs (attemptFailed) and leaves the payment Sent; tests: TestHostStripeFlow/decline_then_succeed (Task 1), /decline_then_succeed_payment_intent (Task 3), /event_table (Tasks 1 and 2), TestStripeOperator "a declined attempt is logged and never rejected" (Task 3: no payment service published, so any transition would fail the test) |
|
||||
| T-18-50 | Repudiation | accept: a paid-at-Stripe signal on a Rejected or Canceled payment dropped without a trace | high | mitigate | refusedAccept logs at Error level with payment, uuid, state, event or browser_return, intent and session; test: TestHostStripeFlow/paid_after_final_state (Task 2: webhook on Canceled and Rejected, browser return on Canceled, exactly three Error records) |
|
||||
| T-18-51 | Information Disclosure | the new Warn and Error log lines | medium | mitigate | only ids, the state and Stripe's code and decline_code are logged; `PaymentError` does not decode the decline message; the body, e-mail and key material are never logged; tests: TestStripeOperator decline subtest asserts the message text appears in no record (Task 3), TestStripeWebhook `last_payment_error decodes its codes` (Task 3) |
|
||||
| T-18-52 | Elevation of Privilege | reviving a final-state payment from a paid signal | high | mitigate | the payment machine stays PHP's (Accept only from Created or Sent); refusedAccept performs no transition, data write, event or Stripe call; test: paid_after_final_state asserts Canceled and Rejected stay and the order is not Paid (Task 2); acceptance: sm-paymentgateway-plugin clean at f55320a (Task 2) |
|
||||
| T-18-53 | Spoofing | forged decline, expiry or cancel events | low | accept | signature verification before lookup (T-18-28) is unchanged, and after this plan these event types cannot change a payment at all |
|
||||
| T-18-54 | Denial of Service | expired sessions and canceled intents leave the payment Sent, and the order waits | low | accept | the gateway's `pg:cancel-sent-payments` closes it after `payment_sent_to_canceled_timeout`, as on PHP; review A-IN-02 stays open as the follow-up that would cancel early after matching the stored session id |
|
||||
| T-18-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is installed in this plan; go.mod and go.sum are not touched (TestSDKPin keeps asserting the stripe-go pin) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: Docker-free vet and tests; decline_then_succeed and event_table on Postgres; TestHostStripeFlow at 15 subtests.
|
||||
- Task 2: Docker-free vet and tests; paid_after_final_state with the log, browser-return, replay and race subtests on Postgres; 16 subtests; the gateway repository untouched.
|
||||
- Task 3: the three new Docker-free subtests and the gate's plugins stage; 17 subtests and the gate's postgres stage with PHASE18_REQUIRE_POSTGRES=1.
|
||||
- Task 4: the plugins stage and a clean plugin tree before the sheet.
|
||||
- After execution, `/msd-verify-work 18` (or the verifier's re-run) re-checks gap 1 against `18-VERIFICATION.md`. VERIFICATION and VALIDATION are updated only through that flow and this plan's SUMMARY.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-2 (gap 1 closed): a declined attempt never makes a Stripe payment unpayable; the retry pays the order in Checkout and PaymentIntent modes.
|
||||
- A paid-at-Stripe payment that cannot be accepted is visible as one Error log line; replays stay silent.
|
||||
- SC-4: the proof host covers the decline-then-succeed and paid-after-final-state flows (17 subtests).
|
||||
- The README and COVERAGE.md state the event mapping as implemented; `go vet` and `go test -short` stay green at every commit; nothing is pushed before the human's reply.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/18-payment-gateway/18-08-SUMMARY.md` when done. It records:
|
||||
- the four sm-pgstripe-plugin commits;
|
||||
- the TestHostStripeFlow subtest list with PASS, and the new Docker-free subtests;
|
||||
- the gate stages run;
|
||||
- the decisions of this plan as implemented (decline non-terminal, async_payment_failed terminal, expired and canceled no-ops, the Error branch with Refunded silent, no event or automatic refund);
|
||||
- the push reply verbatim and the pushed head;
|
||||
- the open follow-ups: A-CR-02's Checkout Session expiry from a PaymentCanceled listener and an admin notice; A-IN-02; the settings `webhook_comment` copy, which lists the three accepting events only, a pre-existing text that this plan leaves unchanged;
|
||||
- any app pointer bump found necessary at Task 4.
|
||||
|
||||
Commit the SUMMARY in summercms.go as a planning doc, separately from the code.
|
||||
</output>
|
||||
@@ -16,8 +16,9 @@
|
||||
| webhook event checkout.session.async_payment_succeeded | INTEGRATE | |
|
||||
| webhook event checkout.session.async_payment_failed | INTEGRATE | |
|
||||
| webhook event payment_intent.succeeded | INTEGRATE | |
|
||||
| webhook event payment_intent.payment_failed | INTEGRATE | |
|
||||
| webhook event checkout.session.expired | OPT-OUT | not needed: an expired session leaves the payment Sent; `pg:cancel-sent-payments` cancels it after the timeout, as PHP did |
|
||||
| webhook event payment_intent.payment_failed | INTEGRATE | logged only (18-08): sent on every declined attempt the customer may retry, so the payment stays Sent; PHP never rejected from a notification |
|
||||
| webhook event checkout.session.expired | OPT-OUT | not needed: the payment stays Sent and `pg:cancel-sent-payments` cancels it after the timeout, as PHP did (re-decided in 18-08) |
|
||||
| webhook event payment_intent.canceled | OPT-OUT | not needed: same as checkout.session.expired; the payment stays Sent until `pg:cancel-sent-payments` (decided in 18-08) |
|
||||
| webhook event charge.refunded / charge.dispute.* | OPT-OUT | not needed yet: refunds are initiated by the plugin (refunds.create); disputes have no PHP counterpart; tracked for a follow-up phase |
|
||||
| webhook event construction (`webhook.ConstructEvent`, full typed event) | OPT-OUT | explicitly out of scope: rejects events from other API release trains (RESEARCH F-4); replaced by minimal local decode |
|
||||
| checkout_sessions.expire / list / line_items | OPT-OUT | not needed: sessions are single-use and never enumerated |
|
||||
|
||||
Reference in New Issue
Block a user