@sadrmanesh/commerce-agent-skills 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +21 -0
  3. package/README.md +141 -2
  4. package/bin/commerce-agent-skills.mjs +368 -0
  5. package/package.json +52 -4
  6. package/rules/b2b-pricing.md +11 -0
  7. package/rules/catalog-import-and-migration.md +11 -0
  8. package/rules/checkout-ux-and-conversion.md +12 -0
  9. package/rules/conversion-event-tracking.md +11 -0
  10. package/rules/ecommerce-seo-structured-data.md +11 -0
  11. package/rules/fraud-and-risk-basics.md +11 -0
  12. package/rules/google-merchant-product-feeds.md +11 -0
  13. package/rules/headless-nextjs-storefront.md +10 -0
  14. package/rules/inventory-and-multi-warehouse.md +10 -0
  15. package/rules/iranian-payment-gateways.md +10 -0
  16. package/rules/marketplace-search-and-ranking.md +10 -0
  17. package/rules/mcp-store-tooling.md +11 -0
  18. package/rules/multi-vendor-payouts.md +11 -0
  19. package/rules/order-and-webhook-reliability.md +12 -0
  20. package/rules/payment-gateway-integration.md +12 -0
  21. package/rules/product-catalog-modeling.md +11 -0
  22. package/rules/product-description-guardrails.md +10 -0
  23. package/rules/returns-and-refunds.md +10 -0
  24. package/rules/rtl-and-persian-localization.md +11 -0
  25. package/rules/shopify-app-development.md +10 -0
  26. package/rules/storefront-accessibility.md +11 -0
  27. package/rules/storefront-performance.md +10 -0
  28. package/rules/stripe-payments.md +10 -0
  29. package/rules/subscription-billing.md +10 -0
  30. package/rules/tax-and-shipping.md +10 -0
  31. package/rules/vector-product-search.md +10 -0
  32. package/rules/woocommerce-plugin-development.md +10 -0
  33. package/rules/woocommerce-rest-api.md +10 -0
  34. package/rules/wordpress-plugin-security.md +10 -0
  35. package/scripts/lib/frontmatter.mjs +218 -0
  36. package/skills/b2b-pricing/SKILL.md +55 -0
  37. package/skills/b2b-pricing/evals/examples/net-terms.md +3 -0
  38. package/skills/b2b-pricing/evals/examples/tiered-wholesale.md +3 -0
  39. package/skills/b2b-pricing/evals/tasks.json +19 -0
  40. package/skills/b2b-pricing/references/price-lists.md +46 -0
  41. package/skills/b2b-pricing/scripts/resolve-price.mjs +93 -0
  42. package/skills/catalog-import-and-migration/SKILL.md +58 -0
  43. package/skills/catalog-import-and-migration/evals/examples/platform-migration-urls.md +3 -0
  44. package/skills/catalog-import-and-migration/evals/examples/rerunnable-import.md +3 -0
  45. package/skills/catalog-import-and-migration/evals/tasks.json +20 -0
  46. package/skills/catalog-import-and-migration/references/field-mapping.md +43 -0
  47. package/skills/catalog-import-and-migration/scripts/check-import-rows.mjs +75 -0
  48. package/skills/catalog-order.txt +29 -0
  49. package/skills/checkout-ux-and-conversion/SKILL.md +86 -0
  50. package/skills/checkout-ux-and-conversion/evals/examples/guest-total.md +3 -0
  51. package/skills/checkout-ux-and-conversion/evals/examples/single-submit.md +3 -0
  52. package/skills/checkout-ux-and-conversion/evals/tasks.json +30 -0
  53. package/skills/checkout-ux-and-conversion/references/checkout-review.md +41 -0
  54. package/skills/conversion-event-tracking/SKILL.md +64 -0
  55. package/skills/conversion-event-tracking/evals/examples/purchase-once.md +3 -0
  56. package/skills/conversion-event-tracking/evals/examples/variant-item-id.md +3 -0
  57. package/skills/conversion-event-tracking/evals/tasks.json +30 -0
  58. package/skills/conversion-event-tracking/references/events.md +36 -0
  59. package/skills/conversion-event-tracking/scripts/check-event.mjs +79 -0
  60. package/skills/ecommerce-seo-structured-data/SKILL.md +84 -0
  61. package/skills/ecommerce-seo-structured-data/assets/example-product.jsonld +15 -0
  62. package/skills/ecommerce-seo-structured-data/evals/examples/extend-woocommerce-schema.md +8 -0
  63. package/skills/ecommerce-seo-structured-data/evals/examples/variant-offer.md +7 -0
  64. package/skills/ecommerce-seo-structured-data/evals/tasks.json +29 -0
  65. package/skills/ecommerce-seo-structured-data/references/product-jsonld.md +83 -0
  66. package/skills/ecommerce-seo-structured-data/scripts/check-product-jsonld.mjs +211 -0
  67. package/skills/fraud-and-risk-basics/SKILL.md +66 -0
  68. package/skills/fraud-and-risk-basics/evals/examples/review-queue.md +3 -0
  69. package/skills/fraud-and-risk-basics/evals/examples/wait-for-3ds.md +3 -0
  70. package/skills/fraud-and-risk-basics/evals/tasks.json +30 -0
  71. package/skills/google-merchant-product-feeds/SKILL.md +70 -0
  72. package/skills/google-merchant-product-feeds/assets/example-item.json +11 -0
  73. package/skills/google-merchant-product-feeds/evals/examples/generated-title.md +3 -0
  74. package/skills/google-merchant-product-feeds/evals/examples/stable-variant-id.md +3 -0
  75. package/skills/google-merchant-product-feeds/evals/tasks.json +30 -0
  76. package/skills/google-merchant-product-feeds/references/attributes.md +39 -0
  77. package/skills/google-merchant-product-feeds/scripts/check-feed-item.mjs +92 -0
  78. package/skills/headless-nextjs-storefront/SKILL.md +69 -0
  79. package/skills/headless-nextjs-storefront/evals/examples/revalidate-stock.md +7 -0
  80. package/skills/headless-nextjs-storefront/evals/examples/server-price.md +3 -0
  81. package/skills/headless-nextjs-storefront/evals/tasks.json +30 -0
  82. package/skills/headless-nextjs-storefront/references/cache-and-cart.md +39 -0
  83. package/skills/inventory-and-multi-warehouse/SKILL.md +61 -0
  84. package/skills/inventory-and-multi-warehouse/evals/examples/reserve-last-unit.md +3 -0
  85. package/skills/inventory-and-multi-warehouse/evals/examples/split-order.md +3 -0
  86. package/skills/inventory-and-multi-warehouse/evals/tasks.json +20 -0
  87. package/skills/inventory-and-multi-warehouse/references/platforms.md +40 -0
  88. package/skills/inventory-and-multi-warehouse/scripts/allocate-stock.mjs +72 -0
  89. package/skills/iranian-payment-gateways/SKILL.md +64 -0
  90. package/skills/iranian-payment-gateways/evals/examples/toman-store.md +3 -0
  91. package/skills/iranian-payment-gateways/evals/examples/zarinpal-callback.md +3 -0
  92. package/skills/iranian-payment-gateways/evals/tasks.json +20 -0
  93. package/skills/iranian-payment-gateways/references/zarinpal-v4.md +44 -0
  94. package/skills/iranian-payment-gateways/scripts/check-verify.mjs +62 -0
  95. package/skills/marketplace-search-and-ranking/SKILL.md +93 -0
  96. package/skills/marketplace-search-and-ranking/evals/examples/index-variant.md +3 -0
  97. package/skills/marketplace-search-and-ranking/evals/examples/normalize-query.md +7 -0
  98. package/skills/marketplace-search-and-ranking/evals/tasks.json +29 -0
  99. package/skills/marketplace-search-and-ranking/references/document-and-ranking.md +56 -0
  100. package/skills/marketplace-search-and-ranking/scripts/normalize-search-text.mjs +40 -0
  101. package/skills/mcp-store-tooling/SKILL.md +49 -0
  102. package/skills/mcp-store-tooling/evals/examples/refund-tool-design.md +3 -0
  103. package/skills/mcp-store-tooling/evals/examples/remote-server-auth.md +3 -0
  104. package/skills/mcp-store-tooling/evals/tasks.json +20 -0
  105. package/skills/mcp-store-tooling/references/mcp-notes.md +67 -0
  106. package/skills/mcp-store-tooling/scripts/check-tool-definitions.mjs +69 -0
  107. package/skills/multi-vendor-payouts/SKILL.md +71 -0
  108. package/skills/multi-vendor-payouts/evals/examples/retry-payout.md +3 -0
  109. package/skills/multi-vendor-payouts/evals/examples/split-paid-order.md +3 -0
  110. package/skills/multi-vendor-payouts/evals/tasks.json +30 -0
  111. package/skills/multi-vendor-payouts/references/ledger.md +35 -0
  112. package/skills/order-and-webhook-reliability/SKILL.md +99 -0
  113. package/skills/order-and-webhook-reliability/evals/examples/dedupe-resource-version.md +3 -0
  114. package/skills/order-and-webhook-reliability/evals/examples/verify-raw-body.md +3 -0
  115. package/skills/order-and-webhook-reliability/evals/tasks.json +29 -0
  116. package/skills/order-and-webhook-reliability/references/woocommerce-webhooks.md +58 -0
  117. package/skills/order-and-webhook-reliability/scripts/verify-hmac-signature.mjs +78 -0
  118. package/skills/payment-gateway-integration/SKILL.md +90 -0
  119. package/skills/payment-gateway-integration/evals/examples/confirmed-capture.md +9 -0
  120. package/skills/payment-gateway-integration/evals/examples/replayed-webhook.md +3 -0
  121. package/skills/payment-gateway-integration/evals/tasks.json +30 -0
  122. package/skills/payment-gateway-integration/references/woocommerce-gateway.md +67 -0
  123. package/skills/product-catalog-modeling/SKILL.md +91 -0
  124. package/skills/product-catalog-modeling/assets/example-catalog.json +45 -0
  125. package/skills/product-catalog-modeling/evals/examples/create-variation.md +10 -0
  126. package/skills/product-catalog-modeling/evals/examples/sync-parent.md +7 -0
  127. package/skills/product-catalog-modeling/evals/tasks.json +29 -0
  128. package/skills/product-catalog-modeling/references/variation-model.md +71 -0
  129. package/skills/product-catalog-modeling/scripts/validate-catalog.mjs +234 -0
  130. package/skills/product-description-guardrails/SKILL.md +84 -0
  131. package/skills/product-description-guardrails/evals/examples/facts-only.md +3 -0
  132. package/skills/product-description-guardrails/evals/examples/label-generated.md +3 -0
  133. package/skills/product-description-guardrails/evals/tasks.json +30 -0
  134. package/skills/product-description-guardrails/references/claims.md +24 -0
  135. package/skills/product-description-guardrails/scripts/check-description.mjs +61 -0
  136. package/skills/returns-and-refunds/SKILL.md +55 -0
  137. package/skills/returns-and-refunds/evals/examples/refund-after-inspection.md +3 -0
  138. package/skills/returns-and-refunds/evals/examples/woocommerce-partial-refund.md +3 -0
  139. package/skills/returns-and-refunds/evals/tasks.json +20 -0
  140. package/skills/returns-and-refunds/references/rma-states.md +37 -0
  141. package/skills/rtl-and-persian-localization/SKILL.md +81 -0
  142. package/skills/rtl-and-persian-localization/evals/examples/document-direction.md +7 -0
  143. package/skills/rtl-and-persian-localization/evals/examples/rial-display.md +7 -0
  144. package/skills/rtl-and-persian-localization/evals/tasks.json +30 -0
  145. package/skills/rtl-and-persian-localization/references/persian-text.md +51 -0
  146. package/skills/rtl-and-persian-localization/scripts/normalize-persian.mjs +62 -0
  147. package/skills/shopify-app-development/SKILL.md +73 -0
  148. package/skills/shopify-app-development/evals/examples/dedupe-delivery.md +3 -0
  149. package/skills/shopify-app-development/evals/examples/verify-webhook.md +3 -0
  150. package/skills/shopify-app-development/evals/tasks.json +29 -0
  151. package/skills/shopify-app-development/references/auth-and-webhooks.md +44 -0
  152. package/skills/storefront-accessibility/SKILL.md +59 -0
  153. package/skills/storefront-accessibility/evals/examples/add-to-cart-announcement.md +3 -0
  154. package/skills/storefront-accessibility/evals/examples/checkout-errors.md +3 -0
  155. package/skills/storefront-accessibility/evals/tasks.json +20 -0
  156. package/skills/storefront-accessibility/references/wcag-checklist.md +41 -0
  157. package/skills/storefront-performance/SKILL.md +59 -0
  158. package/skills/storefront-performance/evals/examples/cache-without-leaking-carts.md +3 -0
  159. package/skills/storefront-performance/evals/examples/slow-product-lcp.md +3 -0
  160. package/skills/storefront-performance/evals/tasks.json +20 -0
  161. package/skills/storefront-performance/references/web-vitals.md +55 -0
  162. package/skills/storefront-performance/scripts/check-web-vitals.mjs +49 -0
  163. package/skills/stripe-payments/SKILL.md +72 -0
  164. package/skills/stripe-payments/evals/examples/checkout-session-delayed.md +3 -0
  165. package/skills/stripe-payments/evals/examples/mark-paid-from-webhook.md +5 -0
  166. package/skills/stripe-payments/evals/examples/partial-refund.md +3 -0
  167. package/skills/stripe-payments/evals/tasks.json +27 -0
  168. package/skills/stripe-payments/references/stripe-objects.md +54 -0
  169. package/skills/stripe-payments/scripts/verify-stripe-signature.mjs +62 -0
  170. package/skills/subscription-billing/SKILL.md +76 -0
  171. package/skills/subscription-billing/evals/examples/cancel-at-period-end.md +3 -0
  172. package/skills/subscription-billing/evals/examples/one-renewal.md +3 -0
  173. package/skills/subscription-billing/evals/tasks.json +30 -0
  174. package/skills/subscription-billing/references/states.md +27 -0
  175. package/skills/tax-and-shipping/SKILL.md +61 -0
  176. package/skills/tax-and-shipping/evals/examples/store-tax-lines.md +3 -0
  177. package/skills/tax-and-shipping/evals/examples/unknown-zone.md +3 -0
  178. package/skills/tax-and-shipping/evals/tasks.json +30 -0
  179. package/skills/tax-and-shipping/references/woocommerce.md +22 -0
  180. package/skills/vector-product-search/SKILL.md +66 -0
  181. package/skills/vector-product-search/evals/examples/reembed-on-title.md +3 -0
  182. package/skills/vector-product-search/evals/examples/sku-before-vector.md +3 -0
  183. package/skills/vector-product-search/evals/tasks.json +30 -0
  184. package/skills/vector-product-search/references/index.md +40 -0
  185. package/skills/woocommerce-plugin-development/SKILL.md +84 -0
  186. package/skills/woocommerce-plugin-development/assets/main-plugin-file.php +28 -0
  187. package/skills/woocommerce-plugin-development/evals/examples/declare-hpos.md +9 -0
  188. package/skills/woocommerce-plugin-development/evals/examples/save-order-meta.md +9 -0
  189. package/skills/woocommerce-plugin-development/evals/tasks.json +32 -0
  190. package/skills/woocommerce-plugin-development/references/hooks.md +46 -0
  191. package/skills/woocommerce-plugin-development/references/hpos.md +84 -0
  192. package/skills/woocommerce-rest-api/SKILL.md +90 -0
  193. package/skills/woocommerce-rest-api/evals/examples/https-basic-auth.md +8 -0
  194. package/skills/woocommerce-rest-api/evals/examples/idempotent-order-create.md +7 -0
  195. package/skills/woocommerce-rest-api/evals/tasks.json +30 -0
  196. package/skills/woocommerce-rest-api/references/endpoints.md +66 -0
  197. package/skills/wordpress-plugin-security/SKILL.md +121 -0
  198. package/skills/wordpress-plugin-security/evals/examples/admin-form-nonce.md +9 -0
  199. package/skills/wordpress-plugin-security/evals/examples/private-rest-route.md +9 -0
  200. package/skills/wordpress-plugin-security/evals/tasks.json +31 -0
  201. package/skills/wordpress-plugin-security/references/review-checklist.md +31 -0
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: storefront-accessibility
3
+ description: "Build product, cart, and checkout pages that meet WCAG 2.2 AA: keyboard use, visible focus, labels and autocomplete, announced errors and cart updates, contrast, and target size. Use when building or reviewing storefront UI, forms, filters, carousels, or checkout for accessibility."
4
+ license: MIT
5
+ metadata:
6
+ author: Naser Sadrmanesh
7
+ version: "0.3.0"
8
+ ---
9
+
10
+ # Storefront accessibility
11
+
12
+ A shopper who cannot reach the buy button with a keyboard, or does not hear that the cart changed, cannot buy. Aim for WCAG 2.2 level AA on every step from search to order confirmation. The success criteria that matter most in a store are listed in [the WCAG checklist](references/wcag-checklist.md).
13
+
14
+ ## Procedure
15
+
16
+ 1. Use native elements first: `<button>` for actions, `<a href>` for navigation, `<select>`, `<input type="radio">`, and `<fieldset>` with `<legend>` for option groups. Add ARIA only where no native element fits.
17
+ 2. Make every action work with a keyboard in a logical order, with a visible focus indicator that is not hidden behind sticky headers or cookie banners.
18
+ 3. Give every form field a visible `<label>` and the right `autocomplete` token (`given-name`, `postal-code`, `email`, `tel`, `cc-number`).
19
+ 4. Announce changes that happen without a page load: "Added to cart", a new cart total, filter result counts. Use a polite live region (`role="status"` or `aria-live="polite"`).
20
+ 5. On a form error, name the field and the fix in text, link the error summary to the fields, set `aria-invalid="true"`, and move focus to the summary or the first invalid field.
21
+ 6. Check color contrast: 4.5:1 for normal text, 3:1 for large text and for focus indicators, borders of inputs, and icons that carry meaning.
22
+ 7. Make touch and click targets at least 24 by 24 CSS pixels, or space them so the 24 pixel circle around each does not overlap another.
23
+ 8. Test with a keyboard only, a screen reader, 200 percent zoom, and an automated checker on the product, cart, and checkout templates.
24
+
25
+ ## Product pages
26
+
27
+ - Variation pickers are radio groups or selects with a group label ("Size"). A color swatch has a text name, not only a color. Unavailable options say so in text.
28
+ - Price changes after picking a variation are announced.
29
+ - Images have `alt` text that says what differs between them ("Back view, blue"). Decorative images have `alt=""`.
30
+ - Carousels have pause controls, do not auto-advance faster than a shopper can read, and can be used with a keyboard.
31
+
32
+ ## Cart and checkout
33
+
34
+ - Quantity steppers are buttons with names ("Increase quantity of Blue mug"), and the input accepts typed values.
35
+ - Do not ask for the same information twice in one flow. Offer "same as shipping" for billing (WCAG 3.3.7 Redundant Entry).
36
+ - Do not require solving a puzzle or remembering a code to sign in or pay, unless an alternative exists (3.3.8 Accessible Authentication). Allow password managers and pasting one-time codes.
37
+ - Session timeouts warn the shopper and let them extend, so a slow shopper does not lose the cart.
38
+ - The order confirmation page has a clear heading and the order number as text.
39
+
40
+ ## Gotchas
41
+
42
+ - `<div onclick>` buttons are not focusable and are not announced as buttons.
43
+ - A placeholder is not a label. It disappears when typing starts and often fails contrast.
44
+ - `outline: none` without a replacement focus style hides where the keyboard is.
45
+ - Opening a mini-cart drawer without moving focus into it, or without returning focus when it closes, strands keyboard users.
46
+ - Infinite scroll without a "load more" button hides the footer and is hard to navigate by keyboard.
47
+ - Disabled "Place order" buttons with no explanation leave shoppers stuck. Keep it enabled and explain errors on submit.
48
+
49
+ ## Verification
50
+
51
+ - The whole purchase, from search to confirmation, completes with a keyboard only.
52
+ - A screen reader announces the add-to-cart result and the new cart count.
53
+ - Submitting checkout with an empty postal code moves focus to an error that names the field.
54
+ - Text and focus indicators meet the contrast ratios above.
55
+ - The page is usable at 200 percent zoom and 320 CSS pixels wide without horizontal scrolling.
56
+
57
+ ## Evaluation
58
+
59
+ Worked examples and the checks they must satisfy are in [evals/tasks.json](evals/tasks.json).
@@ -0,0 +1,3 @@
1
+ Use a real `<button>` for add to cart so it is focusable and announced as a button. After the request succeeds, write "Blue mug added to cart. Cart has 3 items." into a `role="status"` live region that already exists in the page. Update the badge text too, with a visually hidden "items in cart" label.
2
+
3
+ Leave focus on the button after adding. If the mini-cart drawer opens, move focus into it, trap focus while it is open, and return focus to the button when it closes.
@@ -0,0 +1,3 @@
1
+ Give every field a visible `<label>` and keep the placeholder only for an example value. Add `autocomplete` tokens such as `given-name`, `address-line1`, `postal-code`, `email`, and `tel`.
2
+
3
+ On submit, show an error summary with links to each invalid field, write each error in text next to the field, set `aria-invalid="true"`, and move focus to the summary. A red border alone fails for color-blind shoppers, so add an icon and text, and check the border and text contrast against the background.
@@ -0,0 +1,20 @@
1
+ {
2
+ "tasks": [
3
+ {
4
+ "id": "add-to-cart-announcement",
5
+ "task": "The add-to-cart button updates a mini-cart badge with JavaScript. Make the result accessible to screen reader and keyboard users.",
6
+ "expects": {
7
+ "must_include": ["<button>", "role=\"status\"", "focus"],
8
+ "must_not_include": ["<div onclick"]
9
+ }
10
+ },
11
+ {
12
+ "id": "checkout-errors",
13
+ "task": "Review a checkout form whose fields use placeholders as labels and show errors only as red borders. List the fixes.",
14
+ "expects": {
15
+ "must_include": ["<label>", "autocomplete", "aria-invalid", "contrast"],
16
+ "must_not_include": ["placeholder is enough"]
17
+ }
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,41 @@
1
+ # WCAG 2.2 checklist for stores
2
+
3
+ Level A and AA success criteria that storefronts most often fail. Numbers refer to WCAG 2.2.
4
+
5
+ | Criterion | Level | In a store |
6
+ | --- | --- | --- |
7
+ | 1.1.1 Non-text Content | A | Product image `alt`, icon buttons with names, swatches with text |
8
+ | 1.3.1 Info and Relationships | A | Headings, lists, tables for size charts, `fieldset` and `legend` for options |
9
+ | 1.3.5 Identify Input Purpose | AA | `autocomplete` on address, email, phone, and payment fields |
10
+ | 1.4.3 Contrast (Minimum) | AA | 4.5:1 text, 3:1 large text, including sale prices and badges |
11
+ | 1.4.10 Reflow | AA | Usable at 320 CSS pixels wide |
12
+ | 1.4.11 Non-text Contrast | AA | 3:1 for input borders, focus rings, and meaningful icons |
13
+ | 2.1.1 Keyboard | A | Filters, swatches, carousels, mini-cart, and payment fields |
14
+ | 2.2.1 Timing Adjustable | A | Cart or checkout timeouts can be extended |
15
+ | 2.2.2 Pause, Stop, Hide | A | Auto-advancing carousels and countdown banners |
16
+ | 2.4.3 Focus Order | A | Drawers and modals trap and return focus |
17
+ | 2.4.7 Focus Visible | AA | A visible focus style everywhere |
18
+ | 2.4.11 Focus Not Obscured (Minimum) | AA | Sticky headers and cookie banners do not cover focus |
19
+ | 2.5.8 Target Size (Minimum) | AA | 24 by 24 CSS pixels, or enough spacing |
20
+ | 3.3.1 Error Identification | A | Errors in text, naming the field |
21
+ | 3.3.3 Error Suggestion | AA | Say how to fix it: "Enter a 5-digit postal code" |
22
+ | 3.3.7 Redundant Entry | A | "Same as shipping" for billing |
23
+ | 3.3.8 Accessible Authentication (Minimum) | AA | No puzzle or memory test to sign in. Allow paste and password managers. |
24
+ | 4.1.2 Name, Role, Value | A | Custom controls expose role and state, such as `aria-expanded` and `aria-pressed` |
25
+ | 4.1.3 Status Messages | AA | Cart updates and result counts in a live region |
26
+
27
+ ## Live region pattern
28
+
29
+ ```html
30
+ <div role="status" class="visually-hidden" id="cart-status"></div>
31
+ <script>
32
+ function announceCart(message) {
33
+ const region = document.getElementById("cart-status");
34
+ region.textContent = "";
35
+ requestAnimationFrame(() => { region.textContent = message; });
36
+ }
37
+ // announceCart("Blue mug added to cart. Cart has 3 items.");
38
+ </script>
39
+ ```
40
+
41
+ The region must exist in the page before the message is written, or some screen readers miss it.
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: storefront-performance
3
+ description: "Improve storefront Core Web Vitals (LCP, INP, CLS) using field data at the 75th percentile, image and font loading, caching of catalog pages, and lighter third-party scripts. Use when product or category pages feel slow, Web Vitals fail, or a theme, app, or script change needs a performance check."
4
+ license: MIT
5
+ compatibility: "The Web Vitals checker needs Node.js 20 or newer and no packages."
6
+ metadata:
7
+ author: Naser Sadrmanesh
8
+ version: "0.3.0"
9
+ ---
10
+
11
+ # Storefront performance
12
+
13
+ Judge speed by what real shoppers get, not one lab run. Core Web Vitals are measured at the 75th percentile of page loads, split by mobile and desktop. Thresholds and causes are in [the Web Vitals notes](references/web-vitals.md).
14
+
15
+ | Metric | Good | Poor |
16
+ | --- | --- | --- |
17
+ | LCP, Largest Contentful Paint | 2500 ms or less | over 4000 ms |
18
+ | INP, Interaction to Next Paint | 200 ms or less | over 500 ms |
19
+ | CLS, Cumulative Layout Shift | 0.1 or less | over 0.25 |
20
+
21
+ INP replaced First Input Delay as a Core Web Vital in March 2024.
22
+
23
+ ## Procedure
24
+
25
+ 1. Get field data per template (home, category, product, cart, checkout) from the Chrome UX Report, Search Console, or your own real-user monitoring with the `web-vitals` library. Lab tools explain causes but do not decide pass or fail.
26
+ 2. Classify the p75 values: `node scripts/check-web-vitals.mjs vitals.json` prints good, needs improvement, or poor for each template.
27
+ 3. Fix the worst metric on the highest-traffic template first.
28
+ 4. LCP: the LCP element on a product page is usually the main product image. Serve it in a modern format at the displayed size, do not lazy-load it, give it `fetchpriority="high"`, and cut server response time with full-page caching for anonymous shoppers.
29
+ 5. INP: break up long JavaScript tasks, defer third-party tags, and make variation pickers and add-to-cart update the UI before network work finishes.
30
+ 6. CLS: set `width` and `height` (or `aspect-ratio`) on images, reserve space for banners, reviews widgets, and price blocks, and avoid inserting content above what the shopper is reading.
31
+ 7. Recheck field data after 28 days, the window the Chrome UX Report uses.
32
+
33
+ ## Caching a store
34
+
35
+ Cache category and product pages for shoppers without a session. Bypass the cache for cart, checkout, account pages, and requests carrying cart or session cookies. Load the mini-cart count with a small request instead of disabling the page cache. Purge a product's page when its price or stock changes.
36
+
37
+ ## Third-party scripts
38
+
39
+ Chat widgets, review apps, A/B testing, and tag managers often cost more than the theme. List every script with its owner and purpose, remove unused ones, and load the rest after the page is interactive. A/B testing scripts that hide the page while loading hurt LCP directly.
40
+
41
+ ## Gotchas
42
+
43
+ - `loading="lazy"` on the main product image delays LCP.
44
+ - A cookie banner or promo bar inserted at the top shifts the whole page and adds CLS.
45
+ - Web fonts without `font-display: swap` or a matching fallback can hide text or shift it.
46
+ - Caching pages for logged-in shoppers can show one shopper's cart to another.
47
+ - A good Lighthouse score on a desktop connection says little about mobile p75 field data.
48
+ - Large carousels that load every slide image at once compete with the LCP image.
49
+
50
+ ## Verification
51
+
52
+ - Every template's p75 LCP, INP, and CLS is classified, mobile and desktop separately.
53
+ - The LCP image is not lazy-loaded and is requested early.
54
+ - Cart and checkout are never served from a shared page cache.
55
+ - After changes, field data at p75 improves over the next 28 days without a drop in conversion.
56
+
57
+ ## Evaluation
58
+
59
+ Worked examples and the checks they must satisfy are in [evals/tasks.json](evals/tasks.json).
@@ -0,0 +1,3 @@
1
+ Cache product, category, and home pages only for shoppers without a session, and bypass the cache for cart, checkout, and my-account pages, and for any request carrying the `woocommerce_items_in_cart`, `woocommerce_cart_hash`, or `wp_woocommerce_session_` cookie, or a logged-in cookie.
2
+
3
+ Load the mini-cart count with a small uncached request after the page loads, so cached HTML never holds a shopper's cart. Purge a product's page when its price or stock changes.
@@ -0,0 +1,3 @@
1
+ At the 75th percentile, 4.6 seconds is poor (above 4000 ms); the target is 2500 ms or less. The LCP element is the main product image, so remove lazy loading from it, add `fetchpriority="high"`, and serve an AVIF or WebP at the displayed size with `srcset`.
2
+
3
+ Move the review widget below the product details, reserve its height so it does not shift the page, and load its script after the page is interactive. Check Time to First Byte too, and cache the product page for anonymous shoppers. Recheck the field data after 28 days.
@@ -0,0 +1,20 @@
1
+ {
2
+ "tasks": [
3
+ {
4
+ "id": "slow-product-lcp",
5
+ "task": "Mobile field data shows a p75 LCP of 4.6 seconds on product pages. The main image is lazy-loaded and a review widget loads at the top. What do you do?",
6
+ "expects": {
7
+ "must_include": ["75th percentile", "LCP", "fetchpriority", "2500"],
8
+ "must_not_include": ["loading=\"lazy\" on the main"]
9
+ }
10
+ },
11
+ {
12
+ "id": "cache-without-leaking-carts",
13
+ "task": "Add full-page caching to a WooCommerce store without showing one shopper's cart to another.",
14
+ "expects": {
15
+ "must_include": ["cart", "checkout", "cookie", "bypass"],
16
+ "must_not_include": ["cache every page for everyone"]
17
+ }
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,55 @@
1
+ # Web Vitals notes
2
+
3
+ ## Thresholds
4
+
5
+ | Metric | Good | Needs improvement | Poor |
6
+ | --- | --- | --- | --- |
7
+ | LCP | at most 2500 ms | 2500 to 4000 ms | above 4000 ms |
8
+ | INP | at most 200 ms | 200 to 500 ms | above 500 ms |
9
+ | CLS | at most 0.1 | 0.1 to 0.25 | above 0.25 |
10
+
11
+ A page passes when all three are good at the 75th percentile.
12
+
13
+ ## LCP parts
14
+
15
+ LCP time splits into four parts. Find the biggest one first.
16
+
17
+ 1. Time to First Byte: server, cache misses, redirects.
18
+ 2. Resource load delay: the image is discovered late, for example set by JavaScript or CSS.
19
+ 3. Resource load duration: the file is too big.
20
+ 4. Element render delay: render-blocking CSS or scripts, or a hidden page from an A/B testing script.
21
+
22
+ Preload the LCP image when it is not in the initial HTML:
23
+
24
+ ```html
25
+ <link rel="preload" as="image" href="/img/mug-800.avif" imagesrcset="/img/mug-400.avif 400w, /img/mug-800.avif 800w" imagesizes="(max-width: 600px) 100vw, 50vw" fetchpriority="high">
26
+ ```
27
+
28
+ ## Collecting field data
29
+
30
+ ```js
31
+ import { onLCP, onINP, onCLS } from "web-vitals";
32
+
33
+ function send(metric) {
34
+ navigator.sendBeacon("/vitals", JSON.stringify({
35
+ name: metric.name,
36
+ value: metric.value,
37
+ rating: metric.rating,
38
+ template: document.body.dataset.template,
39
+ }));
40
+ }
41
+
42
+ onLCP(send);
43
+ onINP(send);
44
+ onCLS(send);
45
+ ```
46
+
47
+ Group the values by template and device, then take the 75th percentile.
48
+
49
+ ## Input file for the checker
50
+
51
+ ```json
52
+ [
53
+ { "template": "product", "device": "mobile", "lcp": 3100, "inp": 180, "cls": 0.02 }
54
+ ]
55
+ ```
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // Classify p75 Core Web Vitals per template.
3
+ // Usage: node check-web-vitals.mjs <vitals.json>
4
+ import { readFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ export const THRESHOLDS = {
9
+ lcp: { good: 2500, poor: 4000 },
10
+ inp: { good: 200, poor: 500 },
11
+ cls: { good: 0.1, poor: 0.25 },
12
+ };
13
+
14
+ /** Rates one value as "good", "needs-improvement", or "poor". */
15
+ export function rate(metric, value) {
16
+ const threshold = THRESHOLDS[metric];
17
+ if (!threshold) throw new Error(`unknown metric ${metric}`);
18
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(`${metric} must be a number of 0 or more`);
19
+ if (value <= threshold.good) return "good";
20
+ if (value <= threshold.poor) return "needs-improvement";
21
+ return "poor";
22
+ }
23
+
24
+ /** Rates each row and says whether it passes (all three metrics good). */
25
+ export function checkWebVitals(rows) {
26
+ return rows.map((row) => {
27
+ const ratings = {};
28
+ for (const metric of Object.keys(THRESHOLDS)) {
29
+ ratings[metric] = row[metric] === undefined ? "missing" : rate(metric, row[metric]);
30
+ }
31
+ const passes = Object.values(ratings).every((rating) => rating === "good");
32
+ return { template: row.template, device: row.device ?? "all", ratings, passes };
33
+ });
34
+ }
35
+
36
+ const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
37
+ if (isCli) {
38
+ const [file] = process.argv.slice(2);
39
+ if (!file) {
40
+ console.error("Usage: node check-web-vitals.mjs <vitals.json>");
41
+ process.exit(2);
42
+ }
43
+ const results = checkWebVitals(JSON.parse(await readFile(file, "utf8")));
44
+ for (const result of results) {
45
+ const parts = Object.entries(result.ratings).map(([metric, rating]) => `${metric.toUpperCase()} ${rating}`);
46
+ console.log(`${result.passes ? "pass" : "fail"} ${result.template} (${result.device}): ${parts.join(", ")}`);
47
+ }
48
+ process.exit(results.every((result) => result.passes) ? 0 : 1);
49
+ }
@@ -0,0 +1,72 @@
1
+ ---
2
+ name: stripe-payments
3
+ description: "Take card and wallet payments with Stripe PaymentIntents or Checkout Sessions, idempotency keys, and signed webhooks that move the order to paid. Use when adding Stripe to a store, handling Stripe webhooks, or refunding through the Stripe API."
4
+ license: MIT
5
+ compatibility: "The signature checker needs Node.js 20 or newer and no packages."
6
+ metadata:
7
+ author: Naser Sadrmanesh
8
+ version: "0.3.0"
9
+ ---
10
+
11
+ # Stripe payments
12
+
13
+ This skill applies the general rules in the payment gateway skill to Stripe. Event names, objects, and status values are in [the Stripe objects notes](references/stripe-objects.md).
14
+
15
+ ## Procedure
16
+
17
+ 1. Pick one integration per checkout. A **Checkout Session** is a Stripe-hosted page. A **PaymentIntent** with the Payment Element keeps the shopper on your page. Both keep card data on Stripe.
18
+ 2. Create the shop order first, unpaid. Then create the PaymentIntent or Checkout Session on the server with the amount from the order, never from the browser.
19
+ 3. Put the shop order id in `metadata` (`metadata[order_id]`) and send an `Idempotency-Key` header derived from the order and attempt, such as `order-1042-attempt-1`.
20
+ 4. Return only the `client_secret` (PaymentIntent) or the session `url` (Checkout) to the browser. The secret key `sk_...` stays on the server.
21
+ 5. Mark the order paid from a verified webhook: `payment_intent.succeeded` for PaymentIntents, or `checkout.session.completed` with `payment_status` equal to `paid` for Checkout.
22
+ 6. Pin the API version for the account and the webhook endpoint, so a dashboard upgrade does not change payloads under you.
23
+
24
+ ## Amounts
25
+
26
+ Stripe amounts are integers in the currency's smallest unit: `1999` is 19.99 USD. Zero-decimal currencies such as JPY are sent as the whole amount: `500` is 500 JPY. Compute the integer from the order total with the currency's exponent and compare the webhook's `amount_received` and `currency` with the order before marking it paid.
27
+
28
+ ## Webhooks
29
+
30
+ - Read the raw body. A JSON parser that reformats the body breaks the signature.
31
+ - Verify the `Stripe-Signature` header with the endpoint secret `whsec_...`. The header has a timestamp `t` and one or more `v1` signatures. The signed payload is `t`, a dot, and the raw body, hashed with HMAC-SHA256 and hex encoded. Reject timestamps older than the tolerance, 300 seconds by default.
32
+ - Run `node scripts/verify-stripe-signature.mjs <body-file> "<Stripe-Signature header>"` with `STRIPE_WEBHOOK_SECRET` set to check a captured delivery.
33
+ - Store `event.id` in a table with a unique constraint before acting. Stripe can send the same event more than once, and out of order.
34
+ - Return `2xx` quickly after storing the event, then do slow work in a job.
35
+ - For events where order matters, retrieve the object (`GET /v1/payment_intents/{id}`) and act on its current status instead of the event's snapshot.
36
+
37
+ ## Status
38
+
39
+ | PaymentIntent `status` | Shop effect |
40
+ | --- | --- |
41
+ | `requires_payment_method` | Unpaid. After a decline, the shopper can try again with the same intent. |
42
+ | `requires_action` | Unpaid. The shopper must finish 3-D Secure or a redirect. |
43
+ | `processing` | On hold. Bank debits and some wallets settle later. |
44
+ | `requires_capture` | Authorized only. Capture before the authorization expires, or release it. |
45
+ | `succeeded` | Paid. Store the latest charge id. |
46
+ | `canceled` | Cancel the order only if it is still unpaid. |
47
+
48
+ ## Refunds and disputes
49
+
50
+ - Refund with `POST /v1/refunds` and `payment_intent`, plus `amount` for a partial refund. Send an idempotency key per refund attempt and store the refund id.
51
+ - A refund can stay `pending` and can later fail. Update the shop refund from `refund.updated` or `charge.refund.updated`, not from the create response alone.
52
+ - `charge.dispute.created` opens a dispute. Flag the order and collect evidence. Do not refund automatically, because a refund does not close a dispute.
53
+
54
+ ## Gotchas
55
+
56
+ - Creating a new PaymentIntent on every checkout page load leaves many incomplete intents and can charge twice if two are confirmed. Reuse the intent stored on the order.
57
+ - Marking the order paid on the `return_url` page is wrong. The redirect arrives before, after, or without the webhook.
58
+ - Test-mode and live-mode keys, webhook secrets, and object ids are separate. A live event never verifies with the test secret.
59
+ - A `v1` signature compare with `==` leaks timing. Use a constant-time compare.
60
+ - Idempotency keys are kept for at least 24 hours. Reusing a key with different parameters returns an error, not a new payment.
61
+
62
+ ## Verification
63
+
64
+ - A forged webhook, or a real one older than five minutes, is rejected with `400`.
65
+ - Delivering the same `payment_intent.succeeded` event twice sends one receipt and reduces stock once.
66
+ - A `payment_intent.succeeded` whose `amount_received` differs from the order total leaves the order unpaid and flags it.
67
+ - A Checkout Session with `payment_status` `unpaid` (a delayed bank method) does not mark the order paid until `checkout.session.async_payment_succeeded`.
68
+ - A partial refund through `POST /v1/refunds` leaves the order paid for the remainder.
69
+
70
+ ## Evaluation
71
+
72
+ Worked examples and the checks they must satisfy are in [evals/tasks.json](evals/tasks.json).
@@ -0,0 +1,3 @@
1
+ On `checkout.session.completed`, read `payment_status`. For a bank debit it is `unpaid`, so record the PaymentIntent id and leave the order on hold without fulfilling it.
2
+
3
+ When `checkout.session.async_payment_succeeded` arrives, verify it, check the amount, and mark the order paid. If `checkout.session.async_payment_failed` arrives instead, mark the order failed and email the shopper a link to pay again.
@@ -0,0 +1,5 @@
1
+ Create the PaymentIntent on the server from the order total with `metadata[order_id]` and an `Idempotency-Key` per attempt.
2
+
3
+ The order becomes paid only in the webhook handler. Verify the `Stripe-Signature` header against the raw body with the endpoint secret, then insert `event.id` into a table with a unique constraint. If the insert conflicts, return `200` and stop, because the event was already handled.
4
+
5
+ For `payment_intent.succeeded`, load the order from `metadata.order_id`, compare `amount_received` and `currency` with the order total in minor units, and mark it paid once. The browser redirect only shows a pending page until the order record says paid.
@@ -0,0 +1,3 @@
1
+ Call `POST /v1/refunds` with `payment_intent=pi_123` and `amount=500`, because Stripe amounts are integers in cents. Send `Idempotency-Key: refund-1042-1` so a retried request does not refund twice.
2
+
3
+ Create the shop refund record as pending with the returned `re_` id. Update it to succeeded or failed from `refund.updated`. The order stays paid for the remaining 14.99 USD.
@@ -0,0 +1,27 @@
1
+ {
2
+ "tasks": [
3
+ {
4
+ "id": "mark-paid-from-webhook",
5
+ "task": "A store creates a Stripe PaymentIntent at checkout. Explain how and when the order should become paid, including how to handle a duplicate delivery.",
6
+ "expects": {
7
+ "must_include": ["payment_intent.succeeded", "Stripe-Signature", "event.id", "amount_received"],
8
+ "must_not_include": ["return_url marks the order paid"]
9
+ }
10
+ },
11
+ {
12
+ "id": "partial-refund",
13
+ "task": "Refund 5.00 USD of a 19.99 USD Stripe payment from the shop admin and keep the shop record in sync when the refund settles.",
14
+ "expects": {
15
+ "must_include": ["POST /v1/refunds", "500", "Idempotency-Key", "refund.updated"],
16
+ "must_not_include": ["amount=5.00"]
17
+ }
18
+ },
19
+ {
20
+ "id": "checkout-session-delayed",
21
+ "task": "A Stripe Checkout Session completes with a bank debit that settles days later. Say what the webhook handler does now and later.",
22
+ "expects": {
23
+ "must_include": ["checkout.session.completed", "payment_status", "checkout.session.async_payment_succeeded"]
24
+ }
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,54 @@
1
+ # Stripe objects and events
2
+
3
+ ## Objects
4
+
5
+ | Object | Id prefix | Use |
6
+ | --- | --- | --- |
7
+ | PaymentIntent | `pi_` | One payment attempt cycle for one order. Holds `amount`, `currency`, `status`, `metadata`, and `latest_charge`. |
8
+ | Checkout Session | `cs_` | A hosted payment page. Has `payment_status` (`paid`, `unpaid`, `no_payment_required`) and a `payment_intent`. |
9
+ | Charge | `ch_` | The movement of money behind a PaymentIntent. Refunds and disputes point at it. |
10
+ | Refund | `re_` | A full or partial refund. `status` is `pending`, `requires_action`, `succeeded`, `failed`, or `canceled`. |
11
+ | Dispute | `dp_` | A chargeback. Respond with evidence before `evidence_details.due_by`. |
12
+ | Event | `evt_` | A webhook delivery body. `id` is stable across retries, so use it for deduplication. |
13
+
14
+ ## Events a store usually handles
15
+
16
+ | Event | Action |
17
+ | --- | --- |
18
+ | `payment_intent.succeeded` | Mark paid after checking amount and currency |
19
+ | `payment_intent.payment_failed` | Record the decline. Keep the order unpaid. |
20
+ | `payment_intent.canceled` | Cancel the order if still unpaid |
21
+ | `checkout.session.completed` | Mark paid if `payment_status` is `paid`. Otherwise wait. |
22
+ | `checkout.session.async_payment_succeeded` | Mark paid for a delayed method |
23
+ | `checkout.session.async_payment_failed` | Mark failed for a delayed method |
24
+ | `charge.refunded` | Reconcile refunds made outside the shop admin |
25
+ | `refund.updated` | Update a pending refund to succeeded or failed |
26
+ | `charge.dispute.created` | Flag the order and start the evidence task |
27
+
28
+ Subscribe the endpoint only to the events you handle. Every other event still has to return `2xx` if it arrives.
29
+
30
+ ## Signature header
31
+
32
+ ```text
33
+ Stripe-Signature: t=1730300000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
34
+ ```
35
+
36
+ Compute `HMAC-SHA256(secret, "1730300000." + rawBody)` as lowercase hex and compare it with each `v1` value. During secret rotation the header can carry two `v1` values. Ignore `v0`.
37
+
38
+ ## Requests
39
+
40
+ ```bash
41
+ curl https://api.stripe.com/v1/payment_intents \
42
+ -u "$STRIPE_SECRET_KEY:" \
43
+ -H "Idempotency-Key: order-1042-attempt-1" \
44
+ -d amount=1999 -d currency=usd \
45
+ -d "automatic_payment_methods[enabled]=true" \
46
+ -d "metadata[order_id]=1042"
47
+ ```
48
+
49
+ ```bash
50
+ curl https://api.stripe.com/v1/refunds \
51
+ -u "$STRIPE_SECRET_KEY:" \
52
+ -H "Idempotency-Key: refund-1042-1" \
53
+ -d payment_intent=pi_123 -d amount=500
54
+ ```
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ // Verify a Stripe-Signature header against a raw webhook body.
3
+ // Usage: STRIPE_WEBHOOK_SECRET=whsec_... node verify-stripe-signature.mjs <body-file> "<header>" [--now <unix seconds>]
4
+ import { createHmac, timingSafeEqual } from "node:crypto";
5
+ import { readFile } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ export const DEFAULT_TOLERANCE_SECONDS = 300;
10
+
11
+ export function parseStripeSignature(header) {
12
+ const result = { timestamp: null, signatures: [] };
13
+ for (const part of String(header ?? "").split(",")) {
14
+ const index = part.indexOf("=");
15
+ if (index === -1) continue;
16
+ const key = part.slice(0, index).trim();
17
+ const value = part.slice(index + 1).trim();
18
+ if (key === "t" && /^\d+$/.test(value)) result.timestamp = Number(value);
19
+ if (key === "v1" && /^[0-9a-f]{64}$/.test(value)) result.signatures.push(value);
20
+ }
21
+ return result;
22
+ }
23
+
24
+ export function signStripePayload(rawBody, secret, timestamp) {
25
+ return createHmac("sha256", secret).update(`${timestamp}.${rawBody}`, "utf8").digest("hex");
26
+ }
27
+
28
+ /** Returns a list of problems. An empty list means the delivery is authentic and fresh. */
29
+ export function verifyStripeSignature(rawBody, header, secret, { now = Math.floor(Date.now() / 1000), tolerance = DEFAULT_TOLERANCE_SECONDS } = {}) {
30
+ if (typeof secret !== "string" || secret === "") return ["no webhook secret"];
31
+ const { timestamp, signatures } = parseStripeSignature(header);
32
+ const errors = [];
33
+ if (timestamp === null) errors.push("missing t timestamp");
34
+ if (signatures.length === 0) errors.push("missing v1 signature");
35
+ if (errors.length > 0) return errors;
36
+ if (Math.abs(now - timestamp) > tolerance) errors.push(`timestamp outside the ${tolerance} second tolerance`);
37
+ const expected = Buffer.from(signStripePayload(rawBody, secret, timestamp), "hex");
38
+ const matched = signatures.some((signature) => {
39
+ const received = Buffer.from(signature, "hex");
40
+ return received.length === expected.length && timingSafeEqual(received, expected);
41
+ });
42
+ if (!matched) errors.push("no v1 signature matches the body");
43
+ return errors;
44
+ }
45
+
46
+ const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
47
+ if (isCli) {
48
+ const [file, header] = process.argv.slice(2);
49
+ const nowIndex = process.argv.indexOf("--now");
50
+ if (!file || !header) {
51
+ console.error('Usage: STRIPE_WEBHOOK_SECRET=whsec_... node verify-stripe-signature.mjs <body-file> "<header>" [--now <seconds>]');
52
+ process.exit(2);
53
+ }
54
+ const body = await readFile(file, "utf8");
55
+ const options = nowIndex === -1 ? {} : { now: Number(process.argv[nowIndex + 1]) };
56
+ const errors = verifyStripeSignature(body, header, process.env.STRIPE_WEBHOOK_SECRET, options);
57
+ if (errors.length > 0) {
58
+ for (const error of errors) console.error(`- ${error}`);
59
+ process.exit(1);
60
+ }
61
+ console.log("Signature valid.");
62
+ }
@@ -0,0 +1,76 @@
1
+ ---
2
+ name: subscription-billing
3
+ description: "Model subscription state, renewal charges, proration, and dunning without treating a renewal as a new one-time checkout. Use when adding or changing recurring billing, plan changes, cancellation, or failed renewal recovery."
4
+ license: MIT
5
+ metadata:
6
+ author: Naser Sadrmanesh
7
+ version: "0.3.0"
8
+ ---
9
+
10
+ # Subscription billing
11
+
12
+ Use this skill for recurring charges. The first payment still follows the payment skill. A renewal is another charge with its own idempotency key, not a replay of the checkout.
13
+
14
+ WooCommerce core does not include subscriptions. WooCommerce Subscriptions is a separate extension. Shopify subscriptions use selling plans. Use the product the store already runs. Do not invent a cron job that inserts orders with `wp_insert_post`.
15
+
16
+ ## Procedure
17
+
18
+ 1. Store the contract separately from any one invoice: customer, plan, quantity, currency, interval, current period start and end, and status.
19
+ 2. Charge the initial invoice with the payment skill. Move the contract to `active` or `trialing` only after that payment is confirmed, unless the plan really has a trial with no charge.
20
+ 3. When a period ends, create one renewal invoice and one charge. The idempotency key is the subscription id plus the period start.
21
+ 4. On success, advance the period. On failure, follow the dunning schedule. Do not delete the entitlement on the first failure.
22
+ 5. Cancel either at period end or immediately, and record which one the shopper chose. Prorate only when the plan change says to.
23
+
24
+ State transitions are in [the state notes](references/states.md). Worked examples and checks are in [evals/tasks.json](evals/tasks.json).
25
+
26
+ ## Status
27
+
28
+ Use these statuses unless the platform already has its own set. Map the platform's names onto them in one function.
29
+
30
+ | Status | Meaning |
31
+ | --- | --- |
32
+ | `trialing` | Access is on. No charge yet, and a trial end is stored. |
33
+ | `active` | The latest renewal succeeded and the period end is in the future. |
34
+ | `past_due` | A renewal failed and retries remain. Access policy is explicit. |
35
+ | `paused` | The merchant or shopper paused billing. No renewal is attempted. |
36
+ | `canceled` | No further renewals. Access ends now or at the stored period end. |
37
+
38
+ Do not overload `active` to mean "card on file but the last charge failed".
39
+
40
+ ## Renewals
41
+
42
+ - One renewal invoice per period. A retried charge uses the same idempotency key.
43
+ - The amount is the plan price at renewal time, plus tax from the tax skill, unless a scheduled price change says otherwise.
44
+ - Store the provider payment id on that invoice. A second webhook does not create a second invoice.
45
+ - Advance `current_period_end` only after the charge is confirmed.
46
+
47
+ ## Changes and cancellation
48
+
49
+ - Upgrade or downgrade mid-period either waits until period end or prorates. Record the policy on the plan. Do not do both.
50
+ - Proration is a signed amount on an invoice: unused time credited, new time debited. It is not a silent edit of a paid invoice.
51
+ - Cancel at period end sets `cancel_at_period_end`. The status stays `active` until the period ends, then becomes `canceled`.
52
+ - Immediate cancel stops renewal and sets the entitlement end to now. Refund the unused time only if the policy says so, through the payment skill's refund path.
53
+
54
+ ## Dunning
55
+
56
+ - Retry a failed renewal on a short written schedule, for example day 1, day 3, and day 7. Those days are a default to configure, not a measured optimum.
57
+ - After the last retry fails, set `canceled` or leave `past_due` with access off, according to the plan. Tell the shopper which one happened.
58
+ - A shopper who updates the payment method retries the open invoice. It does not open a second period.
59
+
60
+ ## Gotchas
61
+
62
+ - Treating the webhook for the first payment as every renewal will extend the period on a retry.
63
+ - Billing today and also billing at period end double-charges the first cycle.
64
+ - Local midnight is not a period boundary. Store period ends in UTC.
65
+ - A paused subscription that still runs the renewal job will charge a shopper who asked to stop.
66
+
67
+ ## Verification
68
+
69
+ - Two workers processing the same period create one invoice and one charge.
70
+ - A failed renewal does not advance `current_period_end`.
71
+ - Cancel at period end leaves access on until that timestamp and does not create another invoice.
72
+ - A plan change records proration as its own invoice lines.
73
+
74
+ ## Evaluation
75
+
76
+ Worked examples and the checks they must satisfy are in [evals/tasks.json](evals/tasks.json).