From 7bc45ae2aa99c295845462ae4bf5c3190b8bad8f Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:19:15 +0200 Subject: [PATCH 01/21] feat: use sdk with apiVersion forced to latest --- packages/core-components/package.json | 2 +- .../core-components/src/sdk/getSdk.spec.ts | 1 + packages/core-components/src/sdk/index.ts | 25 +- packages/docs/package.json | 6 + packages/react-components/package.json | 2 +- packages/react-hooks-components/package.json | 2 +- pnpm-lock.yaml | 1185 ++++++++++++++++- 7 files changed, 1209 insertions(+), 14 deletions(-) diff --git a/packages/core-components/package.json b/packages/core-components/package.json index d7cedb98..2e205d79 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -56,6 +56,6 @@ }, "dependencies": { "@commercelayer/js-auth": "^8.0.0", - "@commercelayer/sdk": "8.0.0-beta.11" + "@commercelayer/sdk": "https://pkg.pr.new/@commercelayer/sdk@c923f75" } } diff --git a/packages/core-components/src/sdk/getSdk.spec.ts b/packages/core-components/src/sdk/getSdk.spec.ts index 21229d32..12b4488b 100644 --- a/packages/core-components/src/sdk/getSdk.spec.ts +++ b/packages/core-components/src/sdk/getSdk.spec.ts @@ -49,6 +49,7 @@ describe("getSdk", () => { expect(CommerceLayer).toHaveBeenCalledWith({ accessToken: "fake-token", organization: "my-org", + apiVersion: "2026-05", }) expect(result).toBe(mockSdkInstance) }) diff --git a/packages/core-components/src/sdk/index.ts b/packages/core-components/src/sdk/index.ts index 6af69396..c07c7fe9 100644 --- a/packages/core-components/src/sdk/index.ts +++ b/packages/core-components/src/sdk/index.ts @@ -4,9 +4,26 @@ import { type JWTWebApp, jwtDecode, } from "@commercelayer/js-auth" -import type { CommerceLayerClient, ErrorObj, RequestObj, ResponseObj } from "@commercelayer/sdk" +import type { + ApiVersion, + CommerceLayerClient, + ErrorObj, + RequestObj, + ResponseObj, +} from "@commercelayer/sdk" import { CommerceLayer as Sdk } from "@commercelayer/sdk" +/** + * The API version every request is pinned to. + * + * The SDK types `apiVersion` as optional and builds unversioned URLs when it is + * omitted, so leaving it out silently changes which API surface we talk to. Kept as + * an explicit literal rather than derived from `API_SUPPORTED_VERSIONS`: the types are + * generated for one version, so an SDK bump and a version change should be reviewed + * together instead of one dragging the other along. + */ +const API_VERSION: ApiVersion = "2026-05" + type RequestInterceptor = (request: RequestObj) => RequestObj | Promise type ResponseInterceptor = (response: ResponseObj) => ResponseObj | Promise type ErrorInterceptor = (error: ErrorObj) => ErrorObj | Promise @@ -35,7 +52,11 @@ export function getSdk({ }): CommerceLayerClient { const { payload } = jwtDecode(accessToken) const { organization } = payload as JWTIntegration | JWTWebApp | JWTSalesChannel - const sdk = Sdk({ accessToken, organization: organization.slug }) + const sdk = Sdk({ + accessToken, + organization: organization.slug, + apiVersion: API_VERSION, + }) if (interceptors?.request != null) { sdk.addRequestInterceptor(interceptors.request.onSuccess, interceptors.request.onFailure) } diff --git a/packages/docs/package.json b/packages/docs/package.json index 09a4564b..f468120e 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -18,7 +18,13 @@ }, "devDependencies": { "@chromatic-com/storybook": "^5.2.1", + "@babel/core": "^7.29.7", + "@babel/preset-env": "^7.29.7", "@commercelayer/js-auth": "^8.0.0", + "@commercelayer/sdk": "https://pkg.pr.new/@commercelayer/sdk@c923f75", + "@mdx-js/react": "^3.1.1", + "@storybook/addon-actions": "^9.0.8", + "@storybook/addon-backgrounds": "^9.0.8", "@storybook/addon-docs": "^10.5.3", "@storybook/addon-links": "^10.5.3", "@storybook/addon-mcp": "^0.7.0", diff --git a/packages/react-components/package.json b/packages/react-components/package.json index 96583980..4601a2b3 100644 --- a/packages/react-components/package.json +++ b/packages/react-components/package.json @@ -64,7 +64,7 @@ "@commercelayer/core-components": "workspace:*", "@commercelayer/organization-config": "^2.8.4", "@commercelayer/react-hooks-components": "workspace:*", - "@commercelayer/sdk": "8.0.0-beta.11", + "@commercelayer/sdk": "https://pkg.pr.new/@commercelayer/sdk@c923f75", "@iframe-resizer/parent": "^5.5.9", "@stripe/react-stripe-js": "^6.8.1", "@stripe/stripe-js": "^9.10.0", diff --git a/packages/react-hooks-components/package.json b/packages/react-hooks-components/package.json index fbdc782d..eceb8de7 100644 --- a/packages/react-hooks-components/package.json +++ b/packages/react-hooks-components/package.json @@ -50,7 +50,7 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", "@babel/core": "^8.0.1", - "@commercelayer/sdk": "8.0.0-beta.11", + "@commercelayer/sdk": "https://pkg.pr.new/@commercelayer/sdk@c923f75", "@rolldown/plugin-babel": "^0.2.3", "@testing-library/react": "^16.3.2", "@types/react": "^19.2.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96e80193..ed6c6327 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,8 +66,8 @@ importers: specifier: ^8.0.0 version: 8.0.0 '@commercelayer/sdk': - specifier: 8.0.0-beta.11 - version: 8.0.0-beta.11 + specifier: https://pkg.pr.new/@commercelayer/sdk@c923f75 + version: https://pkg.pr.new/@commercelayer/sdk@c923f75 devDependencies: '@arethetypeswrong/cli': specifier: ^0.18.5 @@ -100,12 +100,30 @@ importers: specifier: ^19.2.7 version: 19.2.8(react@19.2.8) devDependencies: + '@babel/core': + specifier: ^7.29.7 + version: 7.29.7(supports-color@7.2.0) + '@babel/preset-env': + specifier: ^7.29.7 + version: 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@chromatic-com/storybook': specifier: ^5.2.1 version: 5.3.0(storybook@10.5.7(@types/react@19.2.18)(react@19.2.8)) '@commercelayer/js-auth': specifier: ^8.0.0 version: 8.0.0 + '@commercelayer/sdk': + specifier: https://pkg.pr.new/@commercelayer/sdk@c923f75 + version: https://pkg.pr.new/@commercelayer/sdk@c923f75 + '@mdx-js/react': + specifier: ^3.1.1 + version: 3.1.1(@types/react@19.2.18)(react@19.2.8) + '@storybook/addon-actions': + specifier: ^9.0.8 + version: 9.0.8 + '@storybook/addon-backgrounds': + specifier: ^9.0.8 + version: 9.0.8 '@storybook/addon-docs': specifier: ^10.5.3 version: 10.5.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(storybook@10.5.7(@types/react@19.2.18)(react@19.2.8))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(yaml@2.9.0)) @@ -173,8 +191,8 @@ importers: specifier: workspace:* version: link:../react-hooks-components '@commercelayer/sdk': - specifier: 8.0.0-beta.11 - version: 8.0.0-beta.11 + specifier: https://pkg.pr.new/@commercelayer/sdk@c923f75 + version: https://pkg.pr.new/@commercelayer/sdk@c923f75 '@iframe-resizer/parent': specifier: ^5.5.9 version: 5.5.9 @@ -298,8 +316,8 @@ importers: specifier: ^8.0.1 version: 8.0.1 '@commercelayer/sdk': - specifier: 8.0.0-beta.11 - version: 8.0.0-beta.11 + specifier: https://pkg.pr.new/@commercelayer/sdk@c923f75 + version: https://pkg.pr.new/@commercelayer/sdk@c923f75 '@rolldown/plugin-babel': specifier: ^0.2.3 version: 0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.7)(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(yaml@2.9.0)) @@ -400,6 +418,10 @@ packages: resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.29.7': resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} @@ -408,6 +430,23 @@ packages: resolution: {integrity: sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.29.7': + resolution: {integrity: sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} @@ -416,16 +455,60 @@ packages: resolution: {integrity: sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@8.0.0': + resolution: {integrity: sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-module-transforms@7.29.7': resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@8.0.1': + resolution: {integrity: sha512-UgAhl1kqiW5ciE0yCXqqvnb4H2n3IELJ7lIIQRezwDPilPEZX5i+Rvbja9MFTkwUn2biEiSMeV31aUzR4Lwakw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@8.0.1': + resolution: {integrity: sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/helper-remap-async-to-generator@7.29.7': + resolution: {integrity: sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -450,6 +533,10 @@ packages: resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-wrap-function@7.29.7': + resolution: {integrity: sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==} + engines: {node: '>=6.9.0'} + '@babel/helpers@7.29.7': resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} @@ -468,6 +555,383 @@ packages: engines: {node: ^22.18.0 || >=24.11.0} hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7': + resolution: {integrity: sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7': + resolution: {integrity: sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7': + resolution: {integrity: sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7': + resolution: {integrity: sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7': + resolution: {integrity: sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7': + resolution: {integrity: sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-assertions@7.29.7': + resolution: {integrity: sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-arrow-functions@7.29.7': + resolution: {integrity: sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.7': + resolution: {integrity: sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.29.7': + resolution: {integrity: sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoped-functions@7.29.7': + resolution: {integrity: sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.29.7': + resolution: {integrity: sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.29.7': + resolution: {integrity: sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.29.7': + resolution: {integrity: sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.29.7': + resolution: {integrity: sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.29.7': + resolution: {integrity: sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.29.7': + resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dotall-regex@7.29.7': + resolution: {integrity: sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-keys@7.29.7': + resolution: {integrity: sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-dynamic-import@7.29.7': + resolution: {integrity: sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-explicit-resource-management@7.29.7': + resolution: {integrity: sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-exponentiation-operator@7.29.7': + resolution: {integrity: sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.29.7': + resolution: {integrity: sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.29.7': + resolution: {integrity: sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.29.7': + resolution: {integrity: sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-json-strings@7.29.7': + resolution: {integrity: sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.29.7': + resolution: {integrity: sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7': + resolution: {integrity: sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.29.7': + resolution: {integrity: sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-amd@7.29.7': + resolution: {integrity: sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-systemjs@8.0.1': + resolution: {integrity: sha512-0NEHanXmnFEnfT2dLKTXnu7m8GXFsnxRgteBC2aH21hYMBwAgxu5dcTdi/Eg+ToI1HbZe0CHwz4XRLgRNQhYoQ==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-modules-umd@7.29.7': + resolution: {integrity: sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-new-target@7.29.7': + resolution: {integrity: sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7': + resolution: {integrity: sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.29.7': + resolution: {integrity: sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.29.7': + resolution: {integrity: sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.29.7': + resolution: {integrity: sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.29.7': + resolution: {integrity: sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.29.7': + resolution: {integrity: sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.29.7': + resolution: {integrity: sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.29.7': + resolution: {integrity: sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.29.7': + resolution: {integrity: sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.29.7': + resolution: {integrity: sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.8': + resolution: {integrity: sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regexp-modifiers@7.29.7': + resolution: {integrity: sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-reserved-words@7.29.7': + resolution: {integrity: sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.29.7': + resolution: {integrity: sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.29.8': + resolution: {integrity: sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.29.7': + resolution: {integrity: sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.29.7': + resolution: {integrity: sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typeof-symbol@7.29.7': + resolution: {integrity: sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-escapes@7.29.7': + resolution: {integrity: sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-property-regex@7.29.7': + resolution: {integrity: sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.29.7': + resolution: {integrity: sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7': + resolution: {integrity: sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/preset-env@7.29.7': + resolution: {integrity: sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-modules@0.1.6-no-external-plugins': + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -616,8 +1080,9 @@ packages: resolution: {integrity: sha512-ZlgIhQx7vu89ZD3eOCSkCe7kNb5LgEGy0p4Gayi75sxfHfJRXKkoX2NC+nkp2pFosi4SBYhlQFcd/EtzStAQGw==} engines: {node: '>=18', pnpm: '>=7'} - '@commercelayer/sdk@8.0.0-beta.11': - resolution: {integrity: sha512-EhbpwW8GOsty1H3/YiEDzPZDKuvoXW9nMbrnkFQ5+sSgEiSkFXDM/tFxImkwagX7PbHKyHHaEBn5SoAPiNBwtw==} + '@commercelayer/sdk@https://pkg.pr.new/@commercelayer/sdk@c923f75': + resolution: {integrity: sha512-34a6up4pJ+8aqI4U5tvpF88BiQ0B+EQ8SGZwC9LwEq9njtmLcAN6B8j2M78C3LFF+Tiq0FoT8NvhvIPWMksReQ==, tarball: https://pkg.pr.new/@commercelayer/sdk@c923f75} + version: 8.0.0-beta.10 engines: {node: '>=20'} '@conventional-changelog/git-client@3.1.2': @@ -1742,6 +2207,12 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@storybook/addon-actions@9.0.8': + resolution: {integrity: sha512-LFePu7PPnWN0Il/uoUpmA5T0J0C7d6haJIbg0pXrjxW2MQVSYXE4S4LSUz8fOImltBDV3xAl6tLPYHFj6VcrOA==} + + '@storybook/addon-backgrounds@9.0.8': + resolution: {integrity: sha512-4Vvr4wYHtiZ8UVWdCahK0XEMU4zNgInnNcVQ31YkUg41MVSY+aoZqtNuxOuRbFzUtjL9/aVsbY0Sg9Lp1/EJ4g==} + '@storybook/addon-docs@10.5.7': resolution: {integrity: sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==} peerDependencies: @@ -2333,6 +2804,21 @@ packages: axios@1.19.0: resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.14.2: + resolution: {integrity: sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + babel-plugin-react-compiler@1.0.0: resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} @@ -2582,6 +3068,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-js-compat@3.50.0: + resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} + engines: {node: '>=6.4.0'} + cosmiconfig@9.0.0: resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} @@ -3390,6 +3880,9 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -4067,6 +4560,24 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.2: + resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + hasBin: true + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -4539,10 +5050,26 @@ packages: resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -4983,6 +5510,10 @@ snapshots: '@types/jsesc': 2.5.1 jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-compilation-targets@7.29.7': dependencies: '@babel/compat-data': 7.29.7 @@ -4999,10 +5530,48 @@ snapshots: lru-cache: 11.5.2 semver: 7.8.5 + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@7.2.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-annotate-as-pure': 7.29.7 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + debug: 4.4.3(supports-color@7.2.0) + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.29.7': {} '@babel/helper-globals@8.0.0': {} + '@babel/helper-member-expression-to-functions@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': dependencies: '@babel/traverse': 7.29.8(supports-color@7.2.0) @@ -5010,6 +5579,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@8.0.0': + dependencies: + '@babel/traverse': 8.0.4 + '@babel/types': 8.0.4 + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) @@ -5019,6 +5593,48 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@8.0.1(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 8.0.0 + '@babel/helper-validator-identifier': 8.0.4 + '@babel/traverse': 8.0.4 + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-plugin-utils@8.0.1(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + + '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-wrap-function': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-string-parser@8.0.0': {} @@ -5031,6 +5647,14 @@ snapshots: '@babel/helper-validator-option@8.0.0': {} + '@babel/helper-wrap-function@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 @@ -5049,6 +5673,483 @@ snapshots: dependencies: '@babel/types': 8.0.4 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + + '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoped-functions@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/template': 7.29.7 + + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-dotall-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-keys@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-dynamic-import@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-exponentiation-operator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-json-strings@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-literals@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-member-expression-literals@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-amd@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-systemjs@8.0.1(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-transforms': 8.0.1(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-validator-identifier': 8.0.4 + + '@babel/plugin-transform-modules-umd@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-new-target@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-numeric-separator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-object-super@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-property-literals@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regenerator@7.29.8(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regexp-modifiers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-reserved-words@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-spread@7.29.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-typeof-symbol@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-escapes@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-property-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-import-assertions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-block-scoped-functions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-computed-properties': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-dotall-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-duplicate-keys': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-exponentiation-operator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-function-name': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-json-strings': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-literals': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-modules-systemjs': 8.0.1(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-numeric-separator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-object-super': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-property-literals': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-regexp-modifiers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-reserved-words': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-spread': 7.29.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-sticky-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-typeof-symbol': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-unicode-escapes': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-unicode-property-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-unicode-sets-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@7.2.0)) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + core-js-compat: 3.50.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/types': 7.29.8 + esutils: 2.0.3 + '@babel/runtime@7.29.7': {} '@babel/template@7.29.7': @@ -5184,7 +6285,7 @@ snapshots: '@commercelayer/js-auth': 7.4.2 merge-anything: 5.1.7 - '@commercelayer/sdk@8.0.0-beta.11': {} + '@commercelayer/sdk@https://pkg.pr.new/@commercelayer/sdk@c923f75': {} '@conventional-changelog/git-client@3.1.2(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.0)': dependencies: @@ -6115,6 +7216,10 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@storybook/addon-actions@9.0.8': {} + + '@storybook/addon-backgrounds@9.0.8': {} + '@storybook/addon-docs@10.5.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(storybook@10.5.7(@types/react@19.2.18)(react@19.2.8))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(yaml@2.9.0))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.2.8) @@ -6679,6 +7784,30 @@ snapshots: - debug - supports-color + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + core-js-compat: 3.50.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + babel-plugin-react-compiler@1.0.0: dependencies: '@babel/types': 7.29.8 @@ -6934,6 +8063,10 @@ snapshots: cookie@1.1.1: {} + core-js-compat@3.50.0: + dependencies: + browserslist: 4.28.8 + cosmiconfig@9.0.0(typescript@6.0.3): dependencies: env-paths: 2.2.1 @@ -7726,6 +8859,8 @@ snapshots: dependencies: p-locate: 4.1.0 + lodash.debounce@4.0.8: {} + log-symbols@4.1.0: dependencies: chalk: 4.1.2 @@ -8824,6 +9959,27 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.2 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.2: + dependencies: + jsesc: 3.1.0 + remark-gfm@4.0.1(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 @@ -9261,8 +10417,19 @@ snapshots: undici@8.10.0: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} + unicode-emoji-modifier-base@1.0.0: {} + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 From d2991a86623a7cc4010b736bb5cfe1f7a84f8fa2 Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:27:08 +0200 Subject: [PATCH 02/21] docs: record the payment_sessions model design as ADRs Consolidate the two existing ADRs into docs/adr/ and add three new ones covering the payment_sessions model: how the Payments Model is detected, the Payment Session lifecycle, and the PlaceOrderButton split. Switch to a date prefix for new ADRs. Sequential numbers collide silently across parallel branches, which is how the repo ended up with two 0001s. Moving the react-components ADR to the repo root also fixes six code comments that already referenced it at that path. Co-Authored-By: Claude Opus 5 (1M context) --- CONTEXT.md | 65 ++++- .../0001-payment-source-effect-invariants.md | 0 .../2026-08-18-payment-session-lifecycle.md | 223 ++++++++++++++++++ .../2026-08-18-payments-model-detection.md | 97 ++++++++ ...-18-place-order-split-by-payments-model.md | 173 ++++++++++++++ 5 files changed, 553 insertions(+), 5 deletions(-) rename {packages/react-components/docs => docs}/adr/0001-payment-source-effect-invariants.md (100%) create mode 100644 docs/adr/2026-08-18-payment-session-lifecycle.md create mode 100644 docs/adr/2026-08-18-payments-model-detection.md create mode 100644 docs/adr/2026-08-18-place-order-split-by-payments-model.md diff --git a/CONTEXT.md b/CONTEXT.md index e83ae717..c2e0973f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,17 +1,50 @@ # React Components — Payment -This context covers the React components and state that let a storefront select a payment method and attach a payment source to an order during checkout. +This context covers the React components and state that let a storefront pay for an order during checkout, under either Payments Model. ## Language +**Payments Model**: +Which of the two mutually exclusive payment models an order uses. Two values, named after the order relationship that carries the payment: **`payment_source`** (the older model: `payment_gateways` + `payment_methods` + a per-gateway payment source) and **`payment_sessions`** (the newer model: `payment_settings` + `payment_sessions`). An order is bound to one model for its whole life; it can never switch. A third transient value, `undetermined`, means the order data needed to decide has not loaded yet. +The API version and the Payments Model are two different things: the version says what the API *can* express (`available_payment_settings` exists only from `2026-05`), the order says which model it *uses*. API version `2026-05` is backward compatible and serves both models, so two organizations on the same version can be on different Payments Models, and a single response can carry both `available_payment_methods` and `available_payment_settings`. +_Avoid_: legacy vs new (ages badly), payments version (collides with the API version, e.g. `2026-05`), v1/v2 + **Payment Method**: -A selectable payment option on an order (a configured gateway option, e.g. "Stripe"). Backed by the `payment_method` resource. +A selectable payment option on an order (a configured gateway option, e.g. "Stripe"). Backed by the `payment_method` resource. Belongs to the `payment_source` Payments Model only. _Avoid_: gateway (as a synonym), payment type **Payment Source**: -The concrete payment instrument record attached to a single order (e.g. `stripe_payment`, `adyen_payment`, `wire_transfer`). Created per order from the selected Payment Method. +The concrete payment instrument record attached to a single order (e.g. `stripe_payment`, `adyen_payment`, `wire_transfer`). Created per order from the selected Payment Method. Belongs to the `payment_source` Payments Model only. _Avoid_: payment, card, token +**Payment Setting**: +A configured payment gateway (Stripe, Adyen, Manual, Gift Card, …) at the organization/market level, modelled per-provider (`payment_setting_manuals`, `payment_setting_stripes`, …). The `payment_sessions`-model counterpart of a Payment Method + Payment Gateway pair. An order lists the ones it can use in `available_payment_settings`. +_Avoid_: payment gateway, payment method + +**Payment Session**: +One intended payment against an order, for an `amount_cents`, through a Payment Setting. The `payment_sessions`-model counterpart of a Payment Source. Its lifecycle is Payment Session → Payment Authorization → Payment Capture (→ Payment Refund). An order can carry several sessions (split payment) — unlike a Payment Source, of which there is at most one. `amount_cents` is set once and never updated; omit it on create and the server sizes the session to the order's remaining amount, while an explicit value is silently capped to it. Its `status` is one of seven values (`unpaid`, `authorized`, `voided`, `paid`, `partially_paid`, `refunded`, `partially_refunded`) and only the middle three count as payment taken. +_Avoid_: payment source, charge, payment intent + +**Payment Authorization**: +The record proving a Payment Session's money was actually taken. A session is only a stated *intent* to pay; a session with a `succeeded` Payment Authorization is the one and only evidence of payment on the `payment_sessions` model. Also what makes the order placeable. +_Avoid_: authorized session, payment (as a synonym for the session) + +**Current Payment Session**: +The Payment Session the shopper's selection points at — the one whose Payment Setting is the selected one and whose `amount_cents` still equals the order total. It is the shopper's choice made durable: the order has no `payment_setting` relationship, so the selection is read back as `order.payment_sessions[].payment_setting`. Browser state is a rendering cache of it, never the authority. +_Avoid_: pending session, selected payment method + +**Placeable**: +Whether the API would accept placing the order. Two distinct things share the word: `order.placeable` and the `_placeable` trigger — but they are **not** two ways to ask the same question, and only one of them is usable. `order.placeable` is transient and served **only in an update response**, never on a `GET`, so it can never gate a button on render. The `_placeable` trigger is a `PATCH` that *validates* the order: 200 with the whole order on success, 422 with a JSON:API `errors` array on failure. Because a failed validation persists nothing, repeating it is cheap. Payment coverage is checked by a default payment rule whose threshold an organization can change — so placeability is a server judgement, never a client calculation. +_Avoid_: "can be placed" (ambiguous between the attribute and the check), validated + +**Reusable Session**: +A Payment Session the library may adopt instead of creating a new one: same Payment Setting, `status` still `unpaid`, not past `expires_at`, and with no Payment Authorization in a terminal failure state. Anything failing that predicate is abandoned in place, not deleted — an `unpaid` session counts toward nothing, and a sales-channel token may be refused the delete anyway. +_Avoid_: pending session, stale session, orphan session (the last one is what an abandoned session *becomes*) + +**Undetermined**: +The window in which the order has not yet been loaded with the relationship needed to tell the Payments Model apart, so neither payment tree may be mounted. It is an observable state that every consumer must render something for — not a transient detail that can be ignored. +_Avoid_: loading (the order may well be loaded; what is missing is the include), unknown + **Customer Payment Source**: A Payment Source saved to a Customer for reuse across orders (a stored card). Selected via the order's `_customer_payment_source_id`. _Avoid_: saved card (informal), wallet @@ -22,8 +55,13 @@ _Avoid_: using "gateway" to mean the Payment Method ## Relationships -- An **Order** has at most one **Payment Method** and one **Payment Source** -- A **Payment Source** is created from the selected **Payment Method** +- An **Order** is on exactly one **Payments Model**, permanently +- On the `payment_source` model: an **Order** has at most one **Payment Method** and one **Payment Source**; the Payment Source is created from the selected Payment Method +- On the `payment_sessions` model: an **Order** has zero or more **Payment Sessions**, each created from one **Payment Setting** +- There is no `order.payment_setting`: the selected **Payment Setting** is reachable only through `order.payment_sessions[].payment_setting` +- A **Payment Session** has at most one **Payment Authorization**; a session without one has taken no money, and one whose authorization failed stays `unpaid` and is abandoned rather than deleted +- A **Payment Session** only counts toward the order's paid amount once its **Payment Authorization** has succeeded — and for a manual Payment Setting that happens in a background job, so it is never immediate +- An **Order** on the `payment_sessions` model can still carry `available_payment_methods`: API version `2026-05` is additive, and the new model simply takes precedence - A **Customer Payment Source** belongs to a **Customer**; selecting one sets the **Order**'s Payment Source ## Example dialogue @@ -31,6 +69,23 @@ _Avoid_: using "gateway" to mean the Payment Method > **Dev:** "When the shopper picks Stripe, do we create the **Payment Source** in the `StripePayment` component?" > **Domain expert:** "No — Stripe has no dedicated creation component. The `StripeGateway`/`PaymentGateway` effect creates the **Payment Source** once the **Payment Method** is selected. Adyen and Braintree, by contrast, create it inside their own components." +> **Dev:** "So on the `payment_sessions` model, `` picks the manual component instead?" +> **Domain expert:** "No. `` only exists on the `payment_source` model — it switches on `payment_source_type`, which the newer model has no equivalent of. On the `payment_sessions` model there are no Payment Methods to iterate, so nothing ever mounts it. The two models are two separate component trees, chosen once per order." + +> **Dev:** "The shopper picked bank transfer and the button says the payment doesn't cover the order. Do I show that?" +> **Domain expert:** "Not yet. Authorizing a manual payment runs in a background job, so the first `_placeable` fails while the money is still being taken. Retry a few times first — that error is only true if it survives the last attempt." + +> **Dev:** "Their previous attempt failed. Do I delete that Payment Session before making a new one?" +> **Domain expert:** "No. It stayed `unpaid`, so it counts toward nothing — leave it. And a sales-channel token may not be allowed to delete it anyway, once a failed authorization is hanging off it." + +> **Dev:** "Where does the library keep which Payment Setting the shopper picked?" +> **Domain expert:** "On the order. The Payment Session is created when they pick, so the choice is `order.payment_sessions[].payment_setting` — it survives a reload, and on a mismatch the order wins over local state. What browser state adds is only the rendering of it." + ## Flagged ambiguities +- ~~Gift cards on the `payment_sessions` model are undecided and out of scope.~~ **Resolved.** A gift card is spent by creating a Payment Session against a `payment_setting_gift_cards` setting — it is a payment method, not an order-level code. On the `payment_sessions` model `GiftCardOrCouponForm` therefore works on the coupon only, overriding an explicit `codeType="gift_card_code"`. Note the trigger named in the original entry never existed: there is no `_gift_card_or_coupon_code`, only the plain order attribute `gift_card_or_coupon_code`, and the components write `gift_card_code` / `coupon_code` directly. See `docs/adr/2026-08-18-payment-session-lifecycle.md`. +- **Three official sources are wrong about this domain**, so verify against `core-api` rather than the SDK types or the docs: `available_payment_methods` is annotated `@deprecated Last available in API version 2017-08` but is still served on `2026-05`; `payment_type` is documented as `"manual_payment"` but the real values are uppercase (`MANUAL`, `GIFT_CARD`, …); and both `status` fields are typed as bare `string` with a single `@example`, hiding state machines of seven and eight values. +- "the session's type" was used to mean the gateway (e.g. "manual") — but `payment_session.type` is always the literal `"payment_sessions"` (the resource type). The gateway is `payment_session.payment_setting.type` (e.g. `payment_setting_manuals`). When someone says "the session type", ask which one they mean. +- "the payment is done" was used for both a created **Payment Session** and a taken payment — resolved: only a `succeeded` **Payment Authorization** means paid; a session on its own means nothing was taken. +- "placeable" was used for both the readable order attribute and the `_placeable` validation trigger — resolved in the glossary above; when someone says "check if it's placeable", ask whether they mean reading the attribute or asking the API. - "set payment source" was used to mean both the async operation that creates/attaches a Payment Source *and* the reducer action that stores it in state — resolved: the operation is `setPaymentSource(...)`, the reducer action is `dispatch({ type: "setPaymentSource" })`. diff --git a/packages/react-components/docs/adr/0001-payment-source-effect-invariants.md b/docs/adr/0001-payment-source-effect-invariants.md similarity index 100% rename from packages/react-components/docs/adr/0001-payment-source-effect-invariants.md rename to docs/adr/0001-payment-source-effect-invariants.md diff --git a/docs/adr/2026-08-18-payment-session-lifecycle.md b/docs/adr/2026-08-18-payment-session-lifecycle.md new file mode 100644 index 00000000..8235a97a --- /dev/null +++ b/docs/adr/2026-08-18-payment-session-lifecycle.md @@ -0,0 +1,223 @@ +# Payment Session lifecycle on the `payment_sessions` model + +## Context + +On the `payment_sessions` model the shopper's choice of how to pay is not an order +attribute — there is no `order.payment_setting`. The choice only exists as a +**Payment Session** created against a **Payment Setting**, read back through +`order.payment_sessions[].payment_setting`. + +This ADR covers how the library creates, reuses and reads those sessions, and where the +**Payment Authorization** fits. The place-order sequence itself is a separate decision: +see `2026-08-18-place-order-split-by-payments-model.md`. + +This iteration implements **`payment_setting_manuals` only**. Progress against the full +set is tracked at the bottom of this document. + +### What the API actually does + +Verified in `core-api`, because none of it is documented and parts of the SDK types and +public docs are wrong (see "Sources that are wrong", below). + +**`amount_cents` is optional on create, and an explicit value is silently capped.** +`PaymentSessionCreate` requires only `payment_setting`. Omitting `amount_cents` makes the +server default it to the order's *remaining* amount, not the total: + +```ruby +# app/models/payment_session.rb:181-189 +def set_amount_cents + self.amount_cents ||= default_amount_cents +end + +def cap_amount_cents + max = default_amount_cents + return unless max + self.amount_cents = [amount_cents, max].min if amount_cents +end +``` + +`default_amount_cents` resolves to `Order#session_amount_cents`, which — despite the name +— is the **remaining** amount: `total_amount_with_taxes_cents - sum(payment-taken sessions)` +(`app/models/concerns/order_amounts.rb:24-28`). The capping is silent, so sending an amount +is not merely redundant, it can produce a session that quietly differs from what was asked. + +**`amount_cents` cannot be changed afterwards.** It is absent from `PaymentSessionUpdate` +("not updatable once the session is created"). Changing an amount means a new session. + +**Session status is a 7-state AASM machine with no per-state timestamps.** + +```ruby +# app/models/payment_session.rb:21-28 — initial: :unpaid +unpaid | authorized | voided | paid | partially_paid | refunded | partially_refunded +# :15 +PAYMENT_TAKEN_STATES = %w(authorized paid partially_paid).freeze +``` + +The `payment_sessions` table has only `expires_at`, `created_at`, `updated_at` — there is +no `authorized_at`/`paid_at`. Transaction resources are the opposite: they *do* carry one +timestamp per state. + +**Transactions share one state machine across all four types.** `PaymentAuthorization`, +`PaymentCapture`, `PaymentVoid` and `PaymentRefund` are STI subclasses of +`PaymentTransaction` and none defines its own states: + +```ruby +# app/models/payment_transaction.rb:22-31 — initial: :pending +pending | requires_action | processing | succeeded | declined | failed | canceled | expired +``` + +**A session only counts once its authorization has succeeded.** The session transitions to +`authorized` from `PaymentAuthorization#change_session_status!`, which runs on +`after_commit ..., if: :succeeded?` (`payment_transaction.rb:82`). A `pending`, +`processing` or `failed` authorization leaves the session `unpaid`, contributing nothing. + +**Authorizing a manual payment is asynchronous.** There is no manual-specific gateway class +(`PaymentSettingManual` is an empty subclass), so it falls through to +`Payment::Session::Base#authorize!`, which merely calls `transaction.succeed!` — but it +runs in a Sidekiq job (`Workers::PaymentTransaction`, queue `payments`) dispatched from +`after_commit :handle_session, on: :create`. The work is trivial; the hop is real. + +## Decision + +### Creation: eager, on selection, with reuse + +Selecting a Payment Setting creates the Payment Session immediately. The selection *is* the +session — that is what makes it survive a reload. + +Create with **`payment_setting` and `order` only**. Never send `amount_cents`; let the +server size the session against the remaining amount. + +Before creating, **reuse** an existing session when all of these hold: + +- its `payment_setting.id` matches the selected setting, **and** +- `status === "unpaid"`, **and** +- `expires_at` is absent or in the future, **and** +- it has no `payment_authorization` in a terminal failure state + (`failed`, `declined`, `canceled`, `expired`). + +### Reading: the order is the only source of truth + +There is **no local selection state**. The current selection is derived from the order on +every render by searching `order.payment_sessions` with the predicate above. Local state is +limited to a per-setting "operation in progress" indicator, which is not a selection. + +The session is always **searched for**, never read positionally. `payment_sessions[0]` is +wrong today for orders carrying a gift-card session and will be wrong for everyone once +split payment is supported. + +### Failed sessions are abandoned, not deleted + +A session whose authorization failed stays `unpaid`, so it is outside +`PAYMENT_TAKEN_STATES` and contributes nothing to any total. The library leaves it on the +order and creates a fresh one. + +Deleting is not merely unnecessary, it may be impossible: a sales-channel or customer token +can be refused the delete when a failed authorization hangs off the session. + +### The authorization is created at place time, not at selection + +Creating it on selection — as the `examples-new-payments` playground does — means choosing +a radio button takes the shopper's money, and changing their mind requires cascade-deleting +an accounting record. Deferring it to the place-order click keeps selection free and +reversible. See the place-order ADR for the full sequence. + +### Setting types are filtered internally, with no public flag + +`` iterates `available_payment_settings` and renders nothing at all for +types the library does not yet implement. There is no `isImplemented` prop: a public flag +would only push the same decision onto every consumer, and it would need deprecating once +the family is complete. + +The two failure modes are not symmetric. Rendering a radio button for an unimplemented +setting produces a control that does nothing when clicked — worse than omitting it. + +## Considered options + +- **Lazy creation** (radio is local state; the session is created by an explicit action). + Rejected: it contradicts the definition of the Current Payment Session and loses the + selection on reload. +- **Eager creation without reuse.** Rejected: because `amount_cents` is immutable, any + remount or refetch that re-triggers the effect creates another session. This repo has + already shipped that bug class twice — commit `242e64a3` and + `0001-coalesce-payment-source-requests.md`. +- **Checking that the reused session's amount covers the order.** Rejected: since the + server sizes the session, verifying the amount client-side means reimplementing the + server's calculation — the same trap as deriving placeability locally. +- **Reading state from timestamps instead of `status`.** Attractive for transactions, but + impossible for sessions, which have no state timestamps at all. Rejected for uniformity. +- **Reusing a session by creating a second authorization on it.** Rejected: + `payment_authorization` is a singular relationship and there is no evidence the API + accepts a replacement. +- **Deleting burnt sessions.** Rejected on both token permissions and necessity. +- **Skipping unimplemented settings but exposing `isImplemented` to the consumer.** + Rejected — see above. + +## Consequences + +Status unions are **hand-written from the AASM machines** and are not in the SDK, which +types both as bare `string`. They must be written as +`"unpaid" | ... | (string & {})` so that unknown values stay assignable, and every branch +that decides on a status needs an explicit `default`. **These unions must be re-checked +against `core-api` whenever the SDK is upgraded.** + +Repeated failed attempts accumulate orphan `unpaid` sessions on the order. They are inert, +but they are the reason every read must search rather than index. + +Reading the selection back requires `payment_sessions.payment_authorization` in the order +`include`, not just `payment_sessions.payment_setting` — without it, a reusable session +cannot be told apart from a burnt one. + +Because selection now round-trips to the API, the radio does not light up on click. A +per-setting pending indicator is required, and it is *not* the selection. + +An organization on the new model with only unimplemented settings configured gets a +checkout with **no payment options and no explanation**. A development-only `console.warn` +fires whenever `` skips a setting; it is not public API and should be +removed once the table below is complete. + +### Payment Setting implementation status + +| Setting | Type literal | Status | +| --- | --- | --- | +| Manual | `payment_setting_manuals` | ✅ implemented | +| Stripe | `payment_setting_stripes` | ⬜ not implemented | +| Adyen | `payment_setting_adyens` | ⬜ not implemented | +| Braintree | `payment_setting_braintrees` | ⬜ not implemented | +| External | `payment_setting_externals` | ⬜ not implemented | +| Gift card | `payment_setting_gift_cards` | ⬜ not implemented | + +### Gift cards + +On this model a gift card is spent by creating a Payment Session against a +`payment_setting_gift_cards` setting, carrying `gift_card_code` and no `amount_cents`. +It is therefore a payment method, not an order-level code. + +Until that setting is implemented, `GiftCardOrCouponForm` pins its code type to +`coupon_code` whenever the model is `payment_sessions` — **overriding an explicit +`codeType="gift_card_code"` prop**. Writing `gift_card_code` on the order is meaningless +here, and allowing it through would silently apply a gift card that no session reflects. +`GiftCardOrCouponCode` and `GiftCardOrCouponRemoveButton` follow the same rule; their +`managePaymentProviderGiftCards` branch is Adyen-specific and unreachable on this model. + +This supersedes the "gift cards are undecided and out of scope" ambiguity previously +flagged in `CONTEXT.md`. + +## Sources that are wrong + +Three official sources were found to contradict `core-api` during the design of this work. +**Verify payment semantics against `core-api`, not against the SDK types or the docs.** + +1. `available_payment_methods` / `payment_method` are annotated + `@deprecated Last available in API version 2017-08` in the SDK types. They are still + served on `2026-05`. +2. `payment_type` is documented with the example `"manual_payment"` + (`config/attributes/payment_transaction.yml:36`). The values the code produces are + uppercase — `MANUAL`, `GIFT_CARD`, `STRIPE`, … — derived from the setting's class name + (`app/models/payment_setting.rb:36-38`). +3. `PaymentSession.status` and `PaymentTransaction.status` are typed as bare `string` in + the SDK with a single `@example` each. The real value sets are the AASM machines quoted + above. + +A fourth, for honesty: during this design `ensure_pending` was assumed from its name to +require a pending order. It does the opposite — it *promotes* a draft order and returns +`true` for every other status (`app/models/order.rb:966-969`). diff --git a/docs/adr/2026-08-18-payments-model-detection.md b/docs/adr/2026-08-18-payments-model-detection.md new file mode 100644 index 00000000..c7f1338d --- /dev/null +++ b/docs/adr/2026-08-18-payments-model-detection.md @@ -0,0 +1,97 @@ +# Detect the Payments Model from the order + +> Naming note: from this ADR onwards, files in this directory use a **date prefix** +> instead of a sequence number. Sequential numbers collide silently across parallel +> feature branches — two branches both pick the next number, both merge cleanly, and +> the directory ends up with duplicates. That already happened here: the two `0001-` +> files predate this convention and are kept under their historical names. + +## Context + +Commerce Layer has two mutually exclusive payment models, and an order is bound to one +for its whole life (see `CONTEXT.md` for the vocabulary): + +- **`payment_source`** — `payment_gateways` + `payment_methods`, surfaced on the order as + `available_payment_methods`, with at most one `payment_source` per order. +- **`payment_sessions`** — `payment_settings` + `payment_sessions`, surfaced on the order + as `available_payment_settings`, with zero or more sessions per order. + +API version `2026-05` is **purely additive**: it adds the new resources and relationships +without removing anything, so a single order response can legitimately carry **both** +`available_payment_methods` and `available_payment_settings`. The library must therefore +decide which model to drive from the order payload itself, not from configuration. + +The library pins the API version for every consumer — `getSdk` passes +`apiVersion: API_VERSION` unconditionally (`packages/core-components/src/sdk/index.ts:58`). +No consumer can reach the API on `2017-08` through these components, so the detection only +ever has to distinguish the two payloads, never two versions. + +### Load-bearing fact: the SDK's deprecation markers are wrong + +`@commercelayer/sdk` v8 annotates the old-model relationships as gone: + +```ts +// api-L7ji9S8h.d.ts:19497 +available_payment_methods?: PaymentMethod[] | null; // @deprecated Last available in API version 2017-08. +// api-L7ji9S8h.d.ts:19507 +payment_method?: PaymentMethod | null; // @deprecated Last available in API version 2017-08. +``` + +**This is inaccurate.** On `2026-05` those relationships are still served. Verified against +the running API. Anyone reading that JSDoc will reach the opposite conclusion and delete +the precedence rule below as dead code — it is not. + +## Decision + +Expose a pure, public hook `usePaymentsModel()` that derives the model from `OrderContext`: + +| Condition | Result | +| --- | --- | +| `available_payment_settings` non-empty | `"payment_sessions"` | +| else `available_payment_methods` non-empty | `"payment_source"` | +| else (including "order not loaded yet") | `"undetermined"` | + +**The precedence lives in the library, never in the consuming application.** When both +arrays are present the new model wins and the old flow is excluded entirely. + +`` registers `available_payment_settings` in the order `include` for **every** +consumer, unconditionally. Without it, an absent array is indistinguishable from an array +that was never requested, and the derivation cannot be trusted. The two nested +session relationships are *not* global — `` registers +`payment_sessions.payment_setting` and `payment_sessions.payment_authorization` when it +mounts, because only the payment UI needs them and they are the expensive part. + +Old and new components **self-silence** by consulting this hook, so both trees can be +mounted side by side without a coordinator. No `PaymentsModelStrategy` component ships in +this iteration. + +## Considered options + +- **A `` wrapper that mounts one branch.** Rejected *for now*, not + on merit: with self-silencing components it saves the consumer a two-line conditional and + nothing else. A public component is forever; a hook plus an inline `switch` is deletable. + Revisit if mfe-checkout finds the manual switch tedious. +- **Have the application blank out `available_payment_methods` after fetching.** Rejected. + It moves a domain rule into every consumer, and every consumer other than mfe-checkout + will get it wrong. It also requires mutating an API response to make components behave. +- **Store the model in the order reducer at fetch time.** Rejected: duplicated state that + can drift from the order it was derived from. A pure derivation cannot drift. +- **Register the nested session includes globally too.** Rejected: two levels of nesting on + a collection, paid by every cart and product page that never mentions payment. + +## Consequences + +`"undetermined"` is a **real, observable state**, not a transient implementation detail. +Every consumer of the hook — the place-order router, the payment setting list, the +gift-card form — must render something sensible during that window. It lasts until the +order has been fetched with the include resolved. + +Every application now pays one extra relationship (`available_payment_settings`) on every +order fetch, including carts that will never show a payment method. This is the price of +making the derivation trustworthy, and it was chosen deliberately over the alternative of +requiring each consumer to opt in. + +Because the deprecation markers cannot be trusted, **verify payment semantics against +`core-api`, not against the SDK types or the public docs.** This rule is not specific to +this ADR — see the closing section of `2026-08-18-payment-session-lifecycle.md` for the +full list of documents found to be wrong. diff --git a/docs/adr/2026-08-18-place-order-split-by-payments-model.md b/docs/adr/2026-08-18-place-order-split-by-payments-model.md new file mode 100644 index 00000000..aec2ae54 --- /dev/null +++ b/docs/adr/2026-08-18-place-order-split-by-payments-model.md @@ -0,0 +1,173 @@ +# Split PlaceOrderButton into two branches behind a router + +## Context + +`PlaceOrderButton` cannot be adapted to the `payment_sessions` model by adding a guard. +Its enablement machine is built entirely on concepts that model does not have. + +The upstream permission check hard-blocks every non-free order: + +```ts +// PlaceOrderReducer.ts:107-149 +total_amount_with_taxes_cents !== 0 && isEmpty(payment_method?.id) // → not permitted +``` + +`order.payment_method` does not exist on the new model, so **every non-free order would be +permanently disabled**. And the block is not a single `if`: the same reducer publishes +`paymentType = payment_method.payment_source_type`, and the button's enablement effect +(`PlaceOrderButton.tsx:103-161`) cross-references it with `currentPaymentMethodType`, +`order.payment_source.payment_response.status`, `getCardDetails(...).brand` and +`currentPaymentMethodRef.current.onsubmit` — four concepts with no counterpart. + +Below that sit five gateway-specific auto-place effects for redirect returns (PayPal payer +id, Stripe payment-intent polling, three Adyen branches guarded on `merchantReference`, +Checkout.com session id, plus an Adyen gift-card callback on an already-placed order), and +a three-branch validation tree inside `handleClick` with a `partially_authorized` override. +Of 598 lines, what carries over is the button ref, the render-prop and +`setPlaceOrderStatus`. + +The constraint from the consuming side is that **the component hierarchy a consumer mounts +must not change**. An application on v5 must upgrade without editing its component tree. + +### What the API actually does + +Verified in `core-api`. + +**`_placeable` is a validation, not a read.** The SDK sends it as +`PATCH /orders/:id` with `attributes: { _placeable: true }`, wired as +`validate :placeable?, if: -> { truthy?(@_placeable) }` (`app/models/order.rb:303`). +On success it returns **200** with the whole order and the transient attribute +`placeable: true`; on failure **422** with a JSON:API `errors` array. Because a failed +validation persists nothing, repeating the call is cheap and side-effect free. + +**`placeable` is never served on a GET.** It is an `attr_accessor` +(`app/models/order.rb:371-378`) exposed as `"transient": true, "actions": ["update"]`, so +it appears only in an update response. It cannot be polled by refetching the order, and it +cannot gate a button on render. + +**Coverage is enforced by a default payment rule, not by a hard-coded guard.** On the new +model `validate_payments` is a no-op — both `validate_payment_method` and +`validate_payment_source` return `true` when `new_payments?` +(`app/models/concerns/order_payments.rb:426-447`) — and no guard in `PLACE_GUARDS` reads +`session_amount_cents`. What does enforce payment is `validate_payment_rules` plus a rule +every market auto-creates: + +```ruby +# app/models/market.rb:16, 31, 155-159 +DEFAULT_PAYMENT_RULE = { template_id: "08ddf06d-...", template_settings: { "value" => 100.0 } }.freeze +after_save :create_default_payment_rule! +``` + +The threshold is a **rule parameter**, so an organization can lower it to accept part +payments. + +**`ensure_pending` does not block an already-placed order.** It promotes a draft and +returns `true` otherwise (`app/models/order.rb:966-969`), so `_placeable` on a placed order +returns 200 rather than an error. This matters because `auto_place` on a Payment Setting +places the order inside the authorization job (`app/models/payment_session.rb:32-35`), +before the client can observe it. + +## Decision + +`PlaceOrderButton` becomes a **pure router** over two implementations: + +- `PlaceOrderButtonPaymentSource` — the current file, **moved unchanged**. Not a refactor: + the 598 lines keep their git blame and their existing specs. +- `PlaceOrderButtonPaymentSessions` — new. + +The router selects on `usePaymentsModel()`. The public props are unchanged; `options` +(`paypalPayerId`, `stripe`, `adyen`, `checkoutCom`) is forwarded **only** to the +`payment_source` branch. + +While the model is `"undetermined"` the router renders a **neutral disabled button** — not +the old branch, and not `null`. Defaulting to the old branch would mount five redirect +effects that read `payment_source.payment_response` on an order that has none; rendering +`null` makes the button appear late, which is a visible change even though the mount +hierarchy is identical. + +`PlaceOrderContext` stays exclusive to the `payment_source` model. The new branch has no +children to serve, and `PrivacyAndTermsCheckbox` already communicates through the +`PLACE_ORDER_RECHECK_EVENT` DOM event rather than context. A consumer that still mounts the +deprecated `` above a new-model order is harmless: the router ignores it. + +The privacy-and-terms gate applies to **both** branches. It is a legal requirement of the +checkout, not a property of the payment model. + +### The new branch's sequence + +1. Create the **Payment Authorization** against the current Payment Session. +2. Poll `_placeable`: **5 attempts, 1 second apart**, by default. + - **200** → read `order.status` from the returned order. + `"placed"` → the order was auto-placed; skip step 3 and report success. + Otherwise → step 3. + - **422** → wait and retry. Do **not** surface the errors yet. +3. `_place`. +4. If the attempts are exhausted, surface the errors from the **last** 422. + +Retrying before reporting is the point of the loop, not an optimization. Authorization is +asynchronous, so the first `_placeable` legitimately fails with "the payment doesn't cover +the required percentage" while the job is still in flight. Reporting that immediately would +tell the shopper their payment failed a second before it succeeded. + +**No client-side coverage gate.** Since the threshold is a configurable rule, a local check +such as `session_amount_cents === 0` would block an order that an organization has +deliberately made placeable with a part payment. Only the server knows the threshold. + +Each 422 error maps to one `BaseError`: `field` from the last segment of +`source.pointer` (`/data/attributes/`, or `base` for `/data`) and `code` from +`meta.error`. One error per reason, so the consumer can address them individually rather +than parsing a concatenated message. Populating `field` also keeps the existing +non-blocking-error filter (`PlaceOrderButton.tsx:162-174`, which exempts coupon and +gift-card fields) working unchanged. + +Attempts and interval are a **parameter of the domain function** in `core-components` and a +prop on `PlaceOrderButtonPaymentSessions`, which forwards it. + +## Considered options + +- **Route inside the existing component.** Rejected: two disjoint state machines in one + file, with the first one untouchable by constraint. +- **Generalise `PlaceOrderContext` to cover both models.** Rejected: it couples the two + machines at the exact seam we are separating. +- **Enable the button from `order.placeable`.** Impossible — it is not served on a GET — + and wrong even if it were: it does not become true until the asynchronous authorization + has succeeded, so it would disable the button precisely while payment is in progress. +- **Derive placeability client-side from session amounts.** Rejected: reimplements a + server rule that organizations can extend, and breaks any organization using a threshold + below 100%. +- **Poll `order.session_amount_cents` by refetching, then call `_placeable` once.** This + was the earlier decision, taken while `_placeable`'s contract was unknown. Superseded: + `_placeable` returns the actual reasons and, on failure, persists nothing, so a second + mechanism buys nothing. +- **Call `_place` directly and use its errors.** Tempting, because `_place` short-circuits + on an already-placed order (`(placed? || placeable?)`, `app/models/order.rb:900-908`). + Rejected: it erases the distinction between checking and acting, so every silent retry + would be a real attempt to place. + +## Consequences + +The router must render a button itself for the `undetermined` window, so `label` and the +render-prop are handled at that level too, not only inside the branches. + +**A 200 from `_placeable` is not proof of payment.** Three routes bypass the coverage rule, +none of them defensible from the client: + +1. The default rule is an ordinary row with no delete protection — it can be removed, + disabled, expired, or its threshold lowered. +2. It is created by an `after_save` on Market with **no backfill migration**, so markets + never re-saved since the feature shipped do not have it. +3. `validate_payment_rules` returns early when + `!new_payments_engaged? && (old_payments? || old_payments_engaged?)`. A `new_payments?` + order with zero sessions but a leftover `payment_method_id` skips rule evaluation + entirely, and since every other guard is a no-op on this model, **it places uncovered**. + +All payment-rule failures arrive with the same `field: payment_action` +(`app/models/business_rule/payment_action.rb:5`), distinguishable only by message text. A +consumer cannot programmatically tell "not covered" from "payment setting not allowed" and +can only display the message. + +The authorization worker is configured `retry: 0` (`app/lib/workers/base.rb:16`), so a +failed job never retries and the authorization stays `pending` **forever**. Exhausting the +attempts is therefore a reachable steady state, not just a slow path. It is reported as a +recoverable condition inviting another attempt — never as a payment failure, and never by +deleting anything, since the payment may in fact have succeeded. From 8f4101befbe3f3499cffcfb48c43f5d80e017df2 Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:29:13 +0200 Subject: [PATCH 03/21] refactor: move PlaceOrderButton to PlaceOrderButtonPaymentSource Pure rename ahead of the Payments Model split. The component's logic is untouched so the history stays blame-able: its enablement machine is built on payment_method / payment_source / payment_response / onsubmit refs, none of which exist on the payment_sessions model. PlaceOrderButton is a temporary re-export alias, replaced by the router in the next commit. The existing spec now targets the branch directly, since its fixtures carry no available_payment_methods and would otherwise resolve to the undetermined state. See docs/adr/2026-08-18-place-order-split-by-payments-model.md Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/orders/place-order.spec.tsx | 2 +- .../components/orders/PlaceOrderButton.tsx | 603 +----------------- .../orders/PlaceOrderButtonPaymentSource.tsx | 598 +++++++++++++++++ packages/react-components/src/index.ts | 1 + 4 files changed, 605 insertions(+), 599 deletions(-) create mode 100644 packages/react-components/src/components/orders/PlaceOrderButtonPaymentSource.tsx diff --git a/packages/react-components/specs/orders/place-order.spec.tsx b/packages/react-components/specs/orders/place-order.spec.tsx index c3ecb454..b403e3e4 100644 --- a/packages/react-components/specs/orders/place-order.spec.tsx +++ b/packages/react-components/specs/orders/place-order.spec.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react" import { type ReactNode, useContext, useEffect } from "react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { PlaceOrderButton } from "#components/orders/PlaceOrderButton" +import { PlaceOrderButtonPaymentSource as PlaceOrderButton } from "#components/orders/PlaceOrderButtonPaymentSource" import { PlaceOrderContainer } from "#components/orders/PlaceOrderContainer" import { PrivacyAndTermsCheckbox } from "#components/orders/PrivacyAndTermsCheckbox" import CommerceLayerContext from "#context/CommerceLayerContext" diff --git a/packages/react-components/src/components/orders/PlaceOrderButton.tsx b/packages/react-components/src/components/orders/PlaceOrderButton.tsx index 3c5626b9..fdd4d08a 100644 --- a/packages/react-components/src/components/orders/PlaceOrderButton.tsx +++ b/packages/react-components/src/components/orders/PlaceOrderButton.tsx @@ -1,598 +1,5 @@ -/** biome-ignore-all lint/correctness/useExhaustiveDependencies: Avoid infinite loop */ -import type { Order } from "@commercelayer/sdk" -import { - type JSX, - type MouseEvent, - type ReactNode, - useContext, - useEffect, - useRef, - useState, -} from "react" -import OrderContext from "#context/OrderContext" -import PaymentMethodContext from "#context/PaymentMethodContext" -import PlaceOrderContext from "#context/PlaceOrderContext" -import useCommerceLayer from "#hooks/useCommerceLayer" -import { usePlaceOrder } from "#hooks/usePlaceOrder" -import type { PlaceOrderOptions } from "#reducers/PlaceOrderReducer" -import type { BaseError } from "#typings/errors" -import type { ChildrenFunction } from "#typings/index" -import getCardDetails from "#utils/getCardDetails" -import { checkPaymentIntent } from "#utils/stripe/retrievePaymentIntent" -import Parent from "../utils/Parent" - -interface ChildrenProps extends Omit { - /** - * Callback function to place the order - */ - handleClick: () => Promise -} - -interface Props extends Omit { - children?: ChildrenFunction - /** - * The label of the button - */ - label?: string | ReactNode | (() => ReactNode) - /** - * The label of the button when it's loading - */ - loadingLabel?: string | ReactNode - /** - * If false, the button doesn't place the order automatically. Default: true - */ - autoPlaceOrder?: boolean - /** - * Callback function that is fired when the button is clicked - */ - onClick?: (response: { placed: boolean; order?: Order; errors?: BaseError[] }) => void - /** - * Place order options (PayPal, Adyen, Stripe, Checkout.com redirect flows). - * Required in standalone mode when used without ``. - */ - options?: PlaceOrderOptions -} - -export function PlaceOrderButton(props: Props): JSX.Element { - const ref = useRef(null) - const { - children, - label = "Place order", - loadingLabel = "Placing...", - autoPlaceOrder = true, - disabled, - onClick, - options: optionsProp, - ...p - } = props - - // Detect standalone mode: no parent has set _isProvided. - const parentCtx = useContext(PlaceOrderContext) - const isStandalone = parentCtx._isProvided !== true - - // Always call the hook (Rules of Hooks). When not standalone, effects are - // guarded internally and the returned value is not used. - const standaloneCtx = usePlaceOrder({ isStandalone, options: optionsProp }) - - const { - isPermitted, - setPlaceOrder, - options, - paymentType, - setButtonRef, - setPlaceOrderStatus, - status, - } = isStandalone ? standaloneCtx : parentCtx - const [notPermitted, setNotPermitted] = useState(true) - const [forceDisable, setForceDisable] = useState(disabled) - const [isLoading, setIsLoading] = useState(false) - const [hasBlockingErrors, setHasBlockingErrors] = useState(false) - const { sdkClient } = useCommerceLayer() - const { - currentPaymentMethodRef, - loading, - currentPaymentMethodType, - paymentSource, - setPaymentSource, - setPaymentMethodErrors, - currentCustomerPaymentSourceId, - errors: paymentMethodErrors, - } = useContext(PaymentMethodContext) - const { order, setOrderErrors, errors } = useContext(OrderContext) - const isFree = order?.total_amount_with_taxes_cents === 0 - useEffect(() => { - if (hasBlockingErrors) { - setNotPermitted(true) - return () => { - setNotPermitted(true) - } - } - // NOTE: no `isFree && !isPermitted` shortcut here. It used to live at this - // spot but was dead code: `setNotPermitted` is a state setter, so the - // branches below ran in the same effect pass and always overwrote it. - if (loading) setNotPermitted(loading) - else { - if (paymentType === currentPaymentMethodType && paymentType) { - const paymentSourceStatus = - // @ts-expect-error no type - order?.payment_source?.payment_response?.status?.toLowerCase?.() - const card = getCardDetails({ - customerPayment: { - payment_source: paymentSource, - }, - paymentType, - }) - if ( - currentCustomerPaymentSourceId != null && - paymentSource?.id === currentCustomerPaymentSourceId && - card.brand === "" - ) { - // Force creadit card icon for customer payment source imported by API - card.brand = "credit-card" - } - if ( - ((isFree && isPermitted) || currentPaymentMethodRef?.current?.onsubmit || card.brand) && - isPermitted - ) { - setNotPermitted(false) - } - if (!currentPaymentMethodRef?.current?.onsubmit && paymentSourceStatus === "declined") { - setNotPermitted(true) - } - } else if (isFree && isPermitted) { - setNotPermitted(false) - } else { - setNotPermitted(true) - } - } - return () => { - setNotPermitted(true) - } - }, [ - isPermitted, - paymentType != null, - !currentPaymentMethodRef?.current?.onsubmit, - loading, - currentPaymentMethodType, - order?.id, - paymentSource?.id, - order?.total_amount_with_taxes_cents, - hasBlockingErrors, - ]) - useEffect(() => { - const giftCardCouponFields = ["gift_card_code", "coupon_code", "gift_card_or_coupon_code"] - const blockingErrors = errors?.filter((e) => !giftCardCouponFields.includes(e.field ?? "")) - const hasErrors = - (blockingErrors != null && blockingErrors.length > 0) || - (paymentMethodErrors != null && paymentMethodErrors.length > 0) - setHasBlockingErrors(hasErrors) - if (hasErrors) { - setNotPermitted(true) - setIsLoading(false) - setForceDisable(false) - } - }, [errors?.length, paymentMethodErrors?.length]) - useEffect(() => { - // PayPal redirect flow - if ( - paymentType === "paypal_payments" && - options?.paypalPayerId && - order?.status && - ["draft", "pending"].includes(order?.status) && - autoPlaceOrder - ) { - handleClick() - } - }, [options?.paypalPayerId, paymentType != null]) - useEffect(() => { - // Stripe redirect flow - if ( - paymentType === "stripe_payments" && - options?.stripe?.paymentIntentClientSecret && - // @ts-expect-error no type - order?.payment_source?.publishable_key && - order?.status && - ["draft", "pending"].includes(order?.status) && - autoPlaceOrder - ) { - // @ts-expect-error no type - const publicApiKey = order?.payment_source?.publishable_key - const paymentIntentClientSecret = options?.stripe?.paymentIntentClientSecret - - const getPaymentIntent = async (): Promise => { - const paymentIntentResult = await checkPaymentIntent({ - publicApiKey, - paymentIntentClientSecret, - }) - switch (paymentIntentResult.status) { - case "valid": - handleClick() - break - case "processing": - // Set a timeout to check the payment intent status again - setTimeout(() => { - getPaymentIntent() - }, 1000) - break - case "invalid": - setPaymentMethodErrors([ - { - code: "PAYMENT_INTENT_AUTHENTICATION_FAILURE", - resource: "payment_methods", - field: currentPaymentMethodType, - message: paymentIntentResult.message, - }, - ]) - break - } - } - getPaymentIntent() - } - }, [ - options?.stripe?.paymentIntentClientSecret != null, - paymentType != null, - order?.payment_source != null, - ]) - useEffect(() => { - if (order?.status != null && ["draft", "pending"].includes(order?.status)) { - // Adyen redirect flow - const isAuthorized = - // @ts-expect-error no type - order?.payment_source?.payment_response?.resultCode === "Authorised" - const paymentDetails = - // @ts-expect-error no type - order?.payment_source?.payment_request_details?.details != null - const paymentStatus = order?.payment_status - const paymentMethodType = - // @ts-expect-error no type - order?.payment_source?.payment_response?.paymentMethod?.type - if (paymentType === "adyen_payments" && options?.adyen?.redirectResult && !paymentDetails) { - const attributes = { - payment_request_details: { - details: { - redirectResult: options?.adyen?.redirectResult, - }, - }, - _details: 1, - } - setPaymentSource({ - paymentSourceId: paymentSource?.id, - paymentResource: "adyen_payments", - attributes, - }).then((res) => { - // @ts-expect-error no type - const resultCode: string = res?.payment_response?.resultCode - // @ts-expect-error no type - const errorCode = res?.payment_response?.errorCode - // @ts-expect-error no type - const message = res?.payment_response?.message - if (["Authorised", "Pending", "Received"].includes(resultCode) && autoPlaceOrder) { - handleClick() - } else if (errorCode != null) { - setPaymentMethodErrors([ - { - code: "PAYMENT_INTENT_AUTHENTICATION_FAILURE", - resource: "payment_methods", - field: currentPaymentMethodType, - message, - }, - ]) - } - }) - } else if ( - paymentType === "adyen_payments" && - isAuthorized && - paymentDetails && - autoPlaceOrder && - status === "standby" && - !options?.adyen?.redirectResult - ) { - // NOTE: This is a workaround for the case when the user reloads the page after selecting a customer payment source - if ( - // @ts-expect-error no type - order?.payment_source?.payment_response?.merchantReference?.includes(order?.number) - ) { - handleClick() - } - } else if ( - paymentType === "adyen_payments" && - isAuthorized && - paymentStatus === "authorized" && - paymentMethodType === "giftcard" && - autoPlaceOrder && - status === "standby" && - !options?.adyen?.redirectResult - ) { - // NOTE: This is a workaround for the case when the user reloads the page after selecting a customer payment source - if ( - // @ts-expect-error no type - order?.payment_source?.payment_response?.merchantReference?.includes(order?.number) - ) { - handleClick() - } - } - } - }, [ - options?.adyen?.redirectResult != null, - // @ts-expect-error no type - order?.payment_source?.payment_response?.resultCode, - ]) - useEffect(() => { - if ( - order?.status === "placed" && - order?.payment_status === "authorized" && - paymentType === "adyen_payments" - ) { - // Dispatch the onClick callback when the order is placed and the payment status is authorized (Adyen with gift card) - onClick?.({ - placed: true, - order: order, - }) - } - }, [order?.id, order?.payment_status, order?.status, paymentType != null]) - useEffect(() => { - // Checkout.com redirect flow - if ( - paymentType === "checkout_com_payments" && - options?.checkoutCom?.session_id && - order?.status && - ["draft", "pending"].includes(order?.status) && - autoPlaceOrder - ) { - // @ts-expect-error no type - const paymentResponse = order?.payment_source?.payment_response - const paymentStatus = paymentResponse?.status - if (paymentStatus && paymentStatus.toLowerCase() === "pending") { - async function placingOrder(): Promise { - const res = await setPaymentSource({ - paymentSourceId: paymentSource?.id, - paymentResource: "checkout_com_payments", - attributes: { - _details: 1, - }, - }) - // @ts-expect-error no type - const paymentStatus: string = res?.payment_response?.status - const isValidStatus = ["authorized", "captured"].includes(paymentStatus?.toLowerCase()) - if (paymentStatus && isValidStatus) { - handleClick() - } else { - if (options?.checkoutCom) { - options.checkoutCom.session_id = undefined - } - setPaymentMethodErrors([ - { - code: "PAYMENT_INTENT_AUTHENTICATION_FAILURE", - resource: "payment_methods", - field: currentPaymentMethodType, - message: paymentStatus, - }, - ]) - } - } - placingOrder() - } - } else if ( - paymentType === "checkout_com_payments" && - order?.status && - status && - ["pending"].includes(order?.status) && - ["placing"].includes(status) && - autoPlaceOrder - ) { - /** - * Place order with Checkout.com using express payments - */ - const paymentSourceStatus = - // @ts-expect-error no type - order?.payment_source?.payment_response?.status - if ( - paymentSourceStatus && - ["captured", "authorized"].includes(paymentSourceStatus.toLowerCase()) - ) { - setPlaceOrder?.({ - paymentSource, - }).then((placed) => { - if (placed?.placed) { - onClick?.(placed) - setPlaceOrderStatus?.({ status: "placing" }) - } else { - setPlaceOrderStatus?.({ status: "standby" }) - } - }) - } - } - }, [options?.checkoutCom?.session_id, order?.payment_source?.id, status]) - useEffect(() => { - if (ref?.current != null && setButtonRef != null) { - setButtonRef(ref) - } - }, [ref?.current]) - useEffect(() => { - switch (status) { - case "disabled": - case "placing": - setNotPermitted(true) - break - // No default — the payment check effect above is the sole authority for enabling - // the button. Enabling unconditionally here (old default case) caused the button - // to be enabled on mount regardless of whether a payment method was selected. - } - }, [status]) - const handleClick = async (e?: MouseEvent): Promise => { - e?.preventDefault() - e?.stopPropagation() - const sdk = sdkClient() - if (sdk == null) return - if (order == null) return - let isValid = true - let currentPaymentStatus = "unpaid" - - const isStripePayment = paymentType === "stripe_payments" - if (!isStripePayment) { - /** - * Check if the order is already placed or in draft status to avoid placing it again - * and to prevent placing a draft order - * @see https://docs.commercelayer.io/core/how-tos/placing-orders/checkout/placing-the-order - */ - const { status, payment_status: paymentStatus } = await sdk.orders.retrieve(order?.id, { - fields: ["status", "payment_status", "payment_source"], - include: ["payment_source"], - }) - const isAlreadyPlaced = status === "placed" - const isDraftOrder = status === "draft" - currentPaymentStatus = paymentStatus ?? "unpaid" - - if (isAlreadyPlaced) { - /** - * Order already placed - */ - setPlaceOrderStatus?.({ status: "placing" }) - onClick?.({ - placed: true, - order: order, - }) - return - } - if (isDraftOrder) { - /** - * Draft order cannot be placed - */ - setPlaceOrderStatus?.({ status: "standby" }) - onClick?.({ - placed: false, - order: order, - errors: [ - { - code: "VALIDATION_ERROR", - resource: "orders", - message: "Draft order cannot be placed", - }, - ], - }) - setOrderErrors([ - { - code: "VALIDATION_ERROR", - resource: "orders", - message: "Draft order cannot be placed", - }, - ]) - return - } - } - setIsLoading(true) - // setForceDisable(true) - const checkPaymentSource = - paymentType !== "stripe_payments" - ? await setPaymentSource({ - // @ts-expect-error no type not be undefined - paymentResource: paymentType, - paymentSourceId: paymentSource?.id, - }) - : paymentSource - const checkPaymentSourceStatus = - // @ts-expect-error no type - checkPaymentSource?.payment_response?.status?.toLowerCase?.() - const card = - paymentType && - getCardDetails({ - paymentType, - customerPayment: { payment_source: checkPaymentSource }, - }) - if ( - currentPaymentMethodRef?.current?.onsubmit && - [!options?.paypalPayerId, !options?.adyen?.MD, !options?.checkoutCom?.session_id].every( - Boolean - ) - ) { - isValid = (await currentPaymentMethodRef.current?.onsubmit({ - // @ts-expect-error no type - paymentSource: checkPaymentSource, - setPlaceOrder, - onclickCallback: onClick, - })) as boolean - if ( - !isValid && - // @ts-expect-error no type - checkPaymentSource?.payment_response?.resultCode === "Authorised" - ) { - isValid = true - } - } else if ( - currentPaymentMethodRef?.current?.onsubmit && - options?.checkoutCom?.session_id && - // @ts-expect-error no type - checkPaymentSource?.payment_response?.status && - // @ts-expect-error no type - checkPaymentSource?.payment_response?.status?.toLowerCase() === "declined" - ) { - /** - * Permit to place order with declined payment using Checkout.com - */ - isValid = (await currentPaymentMethodRef.current?.onsubmit({ - // @ts-expect-error no type - paymentSource: checkPaymentSource, - setPlaceOrder, - onclickCallback: onClick, - })) as boolean - } else if (card?.brand && checkPaymentSourceStatus !== "declined") { - isValid = true - } - if (currentPaymentStatus === "partially_authorized") { - isValid = false - } - if (isValid && setPlaceOrderStatus != null) { - setPlaceOrderStatus({ status: "placing" }) - setForceDisable(true) - } - const placed = - isValid && - setPlaceOrder && - (checkPaymentSource || isFree) && - (await setPlaceOrder({ - paymentSource: checkPaymentSource, - currentCustomerPaymentSourceId, - })) - if (placed && setPlaceOrderStatus != null) { - if (placed.placed) { - setPlaceOrderStatus({ status: "placing" }) - onClick?.(placed) - } else { - setForceDisable(false) - onClick?.(placed) - setIsLoading(false) - setPlaceOrderStatus({ status: "standby" }) - } - } else { - setIsLoading(false) - setPlaceOrderStatus?.({ status: "standby" }) - } - } - const disabledButton = disabled !== undefined ? disabled : notPermitted - const labelButton = isLoading ? loadingLabel : typeof label === "function" ? label() : label - const parentProps = { - ...p, - label, - disabled: disabledButton, - handleClick, - parentRef: ref, - isLoading, - } - return children ? ( - {children} - ) : ( - - ) -} - -export default PlaceOrderButton +// Temporary alias. The next commit turns this file into the Payments Model +// router that picks between the `payment_source` and `payment_sessions` +// branches. Kept here so the move above stays a pure rename in the history. +export { PlaceOrderButtonPaymentSource as PlaceOrderButton } from "./PlaceOrderButtonPaymentSource" +export { default } from "./PlaceOrderButtonPaymentSource" diff --git a/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSource.tsx b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSource.tsx new file mode 100644 index 00000000..430b15b2 --- /dev/null +++ b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSource.tsx @@ -0,0 +1,598 @@ +/** biome-ignore-all lint/correctness/useExhaustiveDependencies: Avoid infinite loop */ +import type { Order } from "@commercelayer/sdk" +import { + type JSX, + type MouseEvent, + type ReactNode, + useContext, + useEffect, + useRef, + useState, +} from "react" +import OrderContext from "#context/OrderContext" +import PaymentMethodContext from "#context/PaymentMethodContext" +import PlaceOrderContext from "#context/PlaceOrderContext" +import useCommerceLayer from "#hooks/useCommerceLayer" +import { usePlaceOrder } from "#hooks/usePlaceOrder" +import type { PlaceOrderOptions } from "#reducers/PlaceOrderReducer" +import type { BaseError } from "#typings/errors" +import type { ChildrenFunction } from "#typings/index" +import getCardDetails from "#utils/getCardDetails" +import { checkPaymentIntent } from "#utils/stripe/retrievePaymentIntent" +import Parent from "../utils/Parent" + +interface ChildrenProps extends Omit { + /** + * Callback function to place the order + */ + handleClick: () => Promise +} + +interface Props extends Omit { + children?: ChildrenFunction + /** + * The label of the button + */ + label?: string | ReactNode | (() => ReactNode) + /** + * The label of the button when it's loading + */ + loadingLabel?: string | ReactNode + /** + * If false, the button doesn't place the order automatically. Default: true + */ + autoPlaceOrder?: boolean + /** + * Callback function that is fired when the button is clicked + */ + onClick?: (response: { placed: boolean; order?: Order; errors?: BaseError[] }) => void + /** + * Place order options (PayPal, Adyen, Stripe, Checkout.com redirect flows). + * Required in standalone mode when used without ``. + */ + options?: PlaceOrderOptions +} + +export function PlaceOrderButtonPaymentSource(props: Props): JSX.Element { + const ref = useRef(null) + const { + children, + label = "Place order", + loadingLabel = "Placing...", + autoPlaceOrder = true, + disabled, + onClick, + options: optionsProp, + ...p + } = props + + // Detect standalone mode: no parent has set _isProvided. + const parentCtx = useContext(PlaceOrderContext) + const isStandalone = parentCtx._isProvided !== true + + // Always call the hook (Rules of Hooks). When not standalone, effects are + // guarded internally and the returned value is not used. + const standaloneCtx = usePlaceOrder({ isStandalone, options: optionsProp }) + + const { + isPermitted, + setPlaceOrder, + options, + paymentType, + setButtonRef, + setPlaceOrderStatus, + status, + } = isStandalone ? standaloneCtx : parentCtx + const [notPermitted, setNotPermitted] = useState(true) + const [forceDisable, setForceDisable] = useState(disabled) + const [isLoading, setIsLoading] = useState(false) + const [hasBlockingErrors, setHasBlockingErrors] = useState(false) + const { sdkClient } = useCommerceLayer() + const { + currentPaymentMethodRef, + loading, + currentPaymentMethodType, + paymentSource, + setPaymentSource, + setPaymentMethodErrors, + currentCustomerPaymentSourceId, + errors: paymentMethodErrors, + } = useContext(PaymentMethodContext) + const { order, setOrderErrors, errors } = useContext(OrderContext) + const isFree = order?.total_amount_with_taxes_cents === 0 + useEffect(() => { + if (hasBlockingErrors) { + setNotPermitted(true) + return () => { + setNotPermitted(true) + } + } + // NOTE: no `isFree && !isPermitted` shortcut here. It used to live at this + // spot but was dead code: `setNotPermitted` is a state setter, so the + // branches below ran in the same effect pass and always overwrote it. + if (loading) setNotPermitted(loading) + else { + if (paymentType === currentPaymentMethodType && paymentType) { + const paymentSourceStatus = + // @ts-expect-error no type + order?.payment_source?.payment_response?.status?.toLowerCase?.() + const card = getCardDetails({ + customerPayment: { + payment_source: paymentSource, + }, + paymentType, + }) + if ( + currentCustomerPaymentSourceId != null && + paymentSource?.id === currentCustomerPaymentSourceId && + card.brand === "" + ) { + // Force creadit card icon for customer payment source imported by API + card.brand = "credit-card" + } + if ( + ((isFree && isPermitted) || currentPaymentMethodRef?.current?.onsubmit || card.brand) && + isPermitted + ) { + setNotPermitted(false) + } + if (!currentPaymentMethodRef?.current?.onsubmit && paymentSourceStatus === "declined") { + setNotPermitted(true) + } + } else if (isFree && isPermitted) { + setNotPermitted(false) + } else { + setNotPermitted(true) + } + } + return () => { + setNotPermitted(true) + } + }, [ + isPermitted, + paymentType != null, + !currentPaymentMethodRef?.current?.onsubmit, + loading, + currentPaymentMethodType, + order?.id, + paymentSource?.id, + order?.total_amount_with_taxes_cents, + hasBlockingErrors, + ]) + useEffect(() => { + const giftCardCouponFields = ["gift_card_code", "coupon_code", "gift_card_or_coupon_code"] + const blockingErrors = errors?.filter((e) => !giftCardCouponFields.includes(e.field ?? "")) + const hasErrors = + (blockingErrors != null && blockingErrors.length > 0) || + (paymentMethodErrors != null && paymentMethodErrors.length > 0) + setHasBlockingErrors(hasErrors) + if (hasErrors) { + setNotPermitted(true) + setIsLoading(false) + setForceDisable(false) + } + }, [errors?.length, paymentMethodErrors?.length]) + useEffect(() => { + // PayPal redirect flow + if ( + paymentType === "paypal_payments" && + options?.paypalPayerId && + order?.status && + ["draft", "pending"].includes(order?.status) && + autoPlaceOrder + ) { + handleClick() + } + }, [options?.paypalPayerId, paymentType != null]) + useEffect(() => { + // Stripe redirect flow + if ( + paymentType === "stripe_payments" && + options?.stripe?.paymentIntentClientSecret && + // @ts-expect-error no type + order?.payment_source?.publishable_key && + order?.status && + ["draft", "pending"].includes(order?.status) && + autoPlaceOrder + ) { + // @ts-expect-error no type + const publicApiKey = order?.payment_source?.publishable_key + const paymentIntentClientSecret = options?.stripe?.paymentIntentClientSecret + + const getPaymentIntent = async (): Promise => { + const paymentIntentResult = await checkPaymentIntent({ + publicApiKey, + paymentIntentClientSecret, + }) + switch (paymentIntentResult.status) { + case "valid": + handleClick() + break + case "processing": + // Set a timeout to check the payment intent status again + setTimeout(() => { + getPaymentIntent() + }, 1000) + break + case "invalid": + setPaymentMethodErrors([ + { + code: "PAYMENT_INTENT_AUTHENTICATION_FAILURE", + resource: "payment_methods", + field: currentPaymentMethodType, + message: paymentIntentResult.message, + }, + ]) + break + } + } + getPaymentIntent() + } + }, [ + options?.stripe?.paymentIntentClientSecret != null, + paymentType != null, + order?.payment_source != null, + ]) + useEffect(() => { + if (order?.status != null && ["draft", "pending"].includes(order?.status)) { + // Adyen redirect flow + const isAuthorized = + // @ts-expect-error no type + order?.payment_source?.payment_response?.resultCode === "Authorised" + const paymentDetails = + // @ts-expect-error no type + order?.payment_source?.payment_request_details?.details != null + const paymentStatus = order?.payment_status + const paymentMethodType = + // @ts-expect-error no type + order?.payment_source?.payment_response?.paymentMethod?.type + if (paymentType === "adyen_payments" && options?.adyen?.redirectResult && !paymentDetails) { + const attributes = { + payment_request_details: { + details: { + redirectResult: options?.adyen?.redirectResult, + }, + }, + _details: 1, + } + setPaymentSource({ + paymentSourceId: paymentSource?.id, + paymentResource: "adyen_payments", + attributes, + }).then((res) => { + // @ts-expect-error no type + const resultCode: string = res?.payment_response?.resultCode + // @ts-expect-error no type + const errorCode = res?.payment_response?.errorCode + // @ts-expect-error no type + const message = res?.payment_response?.message + if (["Authorised", "Pending", "Received"].includes(resultCode) && autoPlaceOrder) { + handleClick() + } else if (errorCode != null) { + setPaymentMethodErrors([ + { + code: "PAYMENT_INTENT_AUTHENTICATION_FAILURE", + resource: "payment_methods", + field: currentPaymentMethodType, + message, + }, + ]) + } + }) + } else if ( + paymentType === "adyen_payments" && + isAuthorized && + paymentDetails && + autoPlaceOrder && + status === "standby" && + !options?.adyen?.redirectResult + ) { + // NOTE: This is a workaround for the case when the user reloads the page after selecting a customer payment source + if ( + // @ts-expect-error no type + order?.payment_source?.payment_response?.merchantReference?.includes(order?.number) + ) { + handleClick() + } + } else if ( + paymentType === "adyen_payments" && + isAuthorized && + paymentStatus === "authorized" && + paymentMethodType === "giftcard" && + autoPlaceOrder && + status === "standby" && + !options?.adyen?.redirectResult + ) { + // NOTE: This is a workaround for the case when the user reloads the page after selecting a customer payment source + if ( + // @ts-expect-error no type + order?.payment_source?.payment_response?.merchantReference?.includes(order?.number) + ) { + handleClick() + } + } + } + }, [ + options?.adyen?.redirectResult != null, + // @ts-expect-error no type + order?.payment_source?.payment_response?.resultCode, + ]) + useEffect(() => { + if ( + order?.status === "placed" && + order?.payment_status === "authorized" && + paymentType === "adyen_payments" + ) { + // Dispatch the onClick callback when the order is placed and the payment status is authorized (Adyen with gift card) + onClick?.({ + placed: true, + order: order, + }) + } + }, [order?.id, order?.payment_status, order?.status, paymentType != null]) + useEffect(() => { + // Checkout.com redirect flow + if ( + paymentType === "checkout_com_payments" && + options?.checkoutCom?.session_id && + order?.status && + ["draft", "pending"].includes(order?.status) && + autoPlaceOrder + ) { + // @ts-expect-error no type + const paymentResponse = order?.payment_source?.payment_response + const paymentStatus = paymentResponse?.status + if (paymentStatus && paymentStatus.toLowerCase() === "pending") { + async function placingOrder(): Promise { + const res = await setPaymentSource({ + paymentSourceId: paymentSource?.id, + paymentResource: "checkout_com_payments", + attributes: { + _details: 1, + }, + }) + // @ts-expect-error no type + const paymentStatus: string = res?.payment_response?.status + const isValidStatus = ["authorized", "captured"].includes(paymentStatus?.toLowerCase()) + if (paymentStatus && isValidStatus) { + handleClick() + } else { + if (options?.checkoutCom) { + options.checkoutCom.session_id = undefined + } + setPaymentMethodErrors([ + { + code: "PAYMENT_INTENT_AUTHENTICATION_FAILURE", + resource: "payment_methods", + field: currentPaymentMethodType, + message: paymentStatus, + }, + ]) + } + } + placingOrder() + } + } else if ( + paymentType === "checkout_com_payments" && + order?.status && + status && + ["pending"].includes(order?.status) && + ["placing"].includes(status) && + autoPlaceOrder + ) { + /** + * Place order with Checkout.com using express payments + */ + const paymentSourceStatus = + // @ts-expect-error no type + order?.payment_source?.payment_response?.status + if ( + paymentSourceStatus && + ["captured", "authorized"].includes(paymentSourceStatus.toLowerCase()) + ) { + setPlaceOrder?.({ + paymentSource, + }).then((placed) => { + if (placed?.placed) { + onClick?.(placed) + setPlaceOrderStatus?.({ status: "placing" }) + } else { + setPlaceOrderStatus?.({ status: "standby" }) + } + }) + } + } + }, [options?.checkoutCom?.session_id, order?.payment_source?.id, status]) + useEffect(() => { + if (ref?.current != null && setButtonRef != null) { + setButtonRef(ref) + } + }, [ref?.current]) + useEffect(() => { + switch (status) { + case "disabled": + case "placing": + setNotPermitted(true) + break + // No default — the payment check effect above is the sole authority for enabling + // the button. Enabling unconditionally here (old default case) caused the button + // to be enabled on mount regardless of whether a payment method was selected. + } + }, [status]) + const handleClick = async (e?: MouseEvent): Promise => { + e?.preventDefault() + e?.stopPropagation() + const sdk = sdkClient() + if (sdk == null) return + if (order == null) return + let isValid = true + let currentPaymentStatus = "unpaid" + + const isStripePayment = paymentType === "stripe_payments" + if (!isStripePayment) { + /** + * Check if the order is already placed or in draft status to avoid placing it again + * and to prevent placing a draft order + * @see https://docs.commercelayer.io/core/how-tos/placing-orders/checkout/placing-the-order + */ + const { status, payment_status: paymentStatus } = await sdk.orders.retrieve(order?.id, { + fields: ["status", "payment_status", "payment_source"], + include: ["payment_source"], + }) + const isAlreadyPlaced = status === "placed" + const isDraftOrder = status === "draft" + currentPaymentStatus = paymentStatus ?? "unpaid" + + if (isAlreadyPlaced) { + /** + * Order already placed + */ + setPlaceOrderStatus?.({ status: "placing" }) + onClick?.({ + placed: true, + order: order, + }) + return + } + if (isDraftOrder) { + /** + * Draft order cannot be placed + */ + setPlaceOrderStatus?.({ status: "standby" }) + onClick?.({ + placed: false, + order: order, + errors: [ + { + code: "VALIDATION_ERROR", + resource: "orders", + message: "Draft order cannot be placed", + }, + ], + }) + setOrderErrors([ + { + code: "VALIDATION_ERROR", + resource: "orders", + message: "Draft order cannot be placed", + }, + ]) + return + } + } + setIsLoading(true) + // setForceDisable(true) + const checkPaymentSource = + paymentType !== "stripe_payments" + ? await setPaymentSource({ + // @ts-expect-error no type not be undefined + paymentResource: paymentType, + paymentSourceId: paymentSource?.id, + }) + : paymentSource + const checkPaymentSourceStatus = + // @ts-expect-error no type + checkPaymentSource?.payment_response?.status?.toLowerCase?.() + const card = + paymentType && + getCardDetails({ + paymentType, + customerPayment: { payment_source: checkPaymentSource }, + }) + if ( + currentPaymentMethodRef?.current?.onsubmit && + [!options?.paypalPayerId, !options?.adyen?.MD, !options?.checkoutCom?.session_id].every( + Boolean + ) + ) { + isValid = (await currentPaymentMethodRef.current?.onsubmit({ + // @ts-expect-error no type + paymentSource: checkPaymentSource, + setPlaceOrder, + onclickCallback: onClick, + })) as boolean + if ( + !isValid && + // @ts-expect-error no type + checkPaymentSource?.payment_response?.resultCode === "Authorised" + ) { + isValid = true + } + } else if ( + currentPaymentMethodRef?.current?.onsubmit && + options?.checkoutCom?.session_id && + // @ts-expect-error no type + checkPaymentSource?.payment_response?.status && + // @ts-expect-error no type + checkPaymentSource?.payment_response?.status?.toLowerCase() === "declined" + ) { + /** + * Permit to place order with declined payment using Checkout.com + */ + isValid = (await currentPaymentMethodRef.current?.onsubmit({ + // @ts-expect-error no type + paymentSource: checkPaymentSource, + setPlaceOrder, + onclickCallback: onClick, + })) as boolean + } else if (card?.brand && checkPaymentSourceStatus !== "declined") { + isValid = true + } + if (currentPaymentStatus === "partially_authorized") { + isValid = false + } + if (isValid && setPlaceOrderStatus != null) { + setPlaceOrderStatus({ status: "placing" }) + setForceDisable(true) + } + const placed = + isValid && + setPlaceOrder && + (checkPaymentSource || isFree) && + (await setPlaceOrder({ + paymentSource: checkPaymentSource, + currentCustomerPaymentSourceId, + })) + if (placed && setPlaceOrderStatus != null) { + if (placed.placed) { + setPlaceOrderStatus({ status: "placing" }) + onClick?.(placed) + } else { + setForceDisable(false) + onClick?.(placed) + setIsLoading(false) + setPlaceOrderStatus({ status: "standby" }) + } + } else { + setIsLoading(false) + setPlaceOrderStatus?.({ status: "standby" }) + } + } + const disabledButton = disabled !== undefined ? disabled : notPermitted + const labelButton = isLoading ? loadingLabel : typeof label === "function" ? label() : label + const parentProps = { + ...p, + label, + disabled: disabledButton, + handleClick, + parentRef: ref, + isLoading, + } + return children ? ( + {children} + ) : ( + + ) +} + +export default PlaceOrderButtonPaymentSource diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts index a96b30ea..4cba1f7f 100644 --- a/packages/react-components/src/index.ts +++ b/packages/react-components/src/index.ts @@ -66,6 +66,7 @@ export * from "#components/orders/OrderNumber" export * from "#components/orders/OrderStorage" export * from "#components/orders/PaymentMethodAmount" export * from "#components/orders/PlaceOrderButton" +export * from "#components/orders/PlaceOrderButtonPaymentSource" export * from "#components/orders/PlaceOrderContainer" export * from "#components/orders/PrivacyAndTermsCheckbox" export * from "#components/orders/ShippingAmount" From 27d86262a85275d91b3c71d6dae1d3b7163b13d9 Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:48:47 +0200 Subject: [PATCH 04/21] feat: support the payment_sessions model with a manual payment setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the first slice of the newer payment model, alongside the existing one rather than replacing it. An application on the older model upgrades without touching its component tree. Detection. usePaymentsModel() derives the model from the order: available_payment_settings wins over available_payment_methods when both are present, which 2026-05 allows since it is purely additive. now asks for available_payment_settings on every fetch — without it an absent array cannot be told apart from one never requested. Domain logic in core-components. Session creation, the reuse and selection predicates and the place-order machine are plain functions with no React, so they are testable without rendering — which is exactly what the 598-line PlaceOrderButton is not. Selection. Choosing a setting creates a Payment Session with no amount_cents; the server sizes it to the remaining amount and silently caps an explicit value. Selection is derived from the order on every render, never held locally, and is single per order: switching leaves the previous session behind — deleting it may be refused for a sales-channel token — so the most recent live session wins. Placing. The authorization is created at place time, not at selection, so changing payment method never has to undo an accounting record. _placeable is then retried 5 times at 1s: authorizing is asynchronous, so an early refusal is not a real failure. There is no client-side coverage gate — the threshold is a payment rule an organization can change. PlaceOrderButton is now a router over the two branches; the older one is untouched. See docs/adr/2026-08-18-*.md Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-18-payment-session-lifecycle.md | 13 + packages/core-components/src/index.ts | 1 + .../payment_sessions/createPaymentSession.ts | 35 +++ .../findCurrentPaymentSession.spec.ts | 92 +++++++ .../findCurrentPaymentSession.ts | 56 ++++ .../findReusablePaymentSession.spec.ts | 144 ++++++++++ .../findReusablePaymentSession.ts | 58 ++++ .../src/payment_sessions/index.ts | 21 ++ .../payment_sessions/mapPlaceabilityErrors.ts | 66 +++++ .../placeOrderWithPaymentSessions.spec.ts | 248 ++++++++++++++++++ .../placeOrderWithPaymentSessions.ts | 130 +++++++++ .../src/payment_sessions/types.ts | 91 +++++++ ...iftCardOrCouponForm.paymentsModel.spec.tsx | 67 +++++ .../specs/hooks/usePaymentsModel.spec.tsx | 71 +++++ .../place-order-payments-model.spec.tsx | 240 +++++++++++++++++ .../payment_settings/PaymentSetting.spec.tsx | 245 +++++++++++++++++ .../OrderReducer.includeRequest.spec.ts | 8 +- .../gift_cards/GiftCardOrCouponCode.tsx | 16 +- .../gift_cards/GiftCardOrCouponForm.tsx | 25 +- .../GiftCardOrCouponRemoveButton.tsx | 11 +- .../components/orders/PlaceOrderButton.tsx | 116 +++++++- .../PlaceOrderButtonPaymentSessions.tsx | 169 ++++++++++++ .../payment_settings/PaymentSetting.tsx | 152 +++++++++++ .../PaymentSettingManualPayment.tsx | 68 +++++ .../payment_settings/PaymentSettingName.tsx | 47 ++++ .../PaymentSettingRadioButton.tsx | 62 +++++ .../context/PaymentSettingChildrenContext.ts | 31 +++ .../src/hooks/useOrderState.ts | 25 ++ .../src/hooks/usePaymentsModel.ts | 54 ++++ packages/react-components/src/index.ts | 6 + .../src/reducers/OrderReducer.ts | 8 + 31 files changed, 2361 insertions(+), 15 deletions(-) create mode 100644 packages/core-components/src/payment_sessions/createPaymentSession.ts create mode 100644 packages/core-components/src/payment_sessions/findCurrentPaymentSession.spec.ts create mode 100644 packages/core-components/src/payment_sessions/findCurrentPaymentSession.ts create mode 100644 packages/core-components/src/payment_sessions/findReusablePaymentSession.spec.ts create mode 100644 packages/core-components/src/payment_sessions/findReusablePaymentSession.ts create mode 100644 packages/core-components/src/payment_sessions/index.ts create mode 100644 packages/core-components/src/payment_sessions/mapPlaceabilityErrors.ts create mode 100644 packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.spec.ts create mode 100644 packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts create mode 100644 packages/core-components/src/payment_sessions/types.ts create mode 100644 packages/react-components/specs/gift_cards/GiftCardOrCouponForm.paymentsModel.spec.tsx create mode 100644 packages/react-components/specs/hooks/usePaymentsModel.spec.tsx create mode 100644 packages/react-components/specs/orders/place-order-payments-model.spec.tsx create mode 100644 packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx create mode 100644 packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx create mode 100644 packages/react-components/src/components/payment_settings/PaymentSetting.tsx create mode 100644 packages/react-components/src/components/payment_settings/PaymentSettingManualPayment.tsx create mode 100644 packages/react-components/src/components/payment_settings/PaymentSettingName.tsx create mode 100644 packages/react-components/src/components/payment_settings/PaymentSettingRadioButton.tsx create mode 100644 packages/react-components/src/context/PaymentSettingChildrenContext.ts create mode 100644 packages/react-components/src/hooks/usePaymentsModel.ts diff --git a/docs/adr/2026-08-18-payment-session-lifecycle.md b/docs/adr/2026-08-18-payment-session-lifecycle.md index 8235a97a..15250b59 100644 --- a/docs/adr/2026-08-18-payment-session-lifecycle.md +++ b/docs/adr/2026-08-18-payment-session-lifecycle.md @@ -105,6 +105,19 @@ The session is always **searched for**, never read positionally. `payment_sessio wrong today for orders carrying a gift-card session and will be wrong for everyone once split payment is supported. +**The selection is single per order, and it is the most recent live session.** This +followed from the decision not to delete: switching setting leaves the previous session on +the order, so a per-setting reading of "is this selected?" would light up every setting the +shopper has ever tried at once — a radio group with several selections. Taking the newest +keeps the group coherent without deleting anything a token may be refused. + +Consequence for the reuse rule above: with only one setting implemented, the *adopt* branch +is currently unreachable through the UI, because a reusable session already reads as +selected and the radio ignores a click on the current selection. What is reachable, and +covered by tests, is the retry path — a burnt session does not count as the selection, so +clicking again creates a fresh one. The adopt branch is kept because it becomes live as +soon as a second setting exists. + ### Failed sessions are abandoned, not deleted A session whose authorization failed stays `unpaid`, so it is outside diff --git a/packages/core-components/src/index.ts b/packages/core-components/src/index.ts index 338e9393..c2f0ba6b 100644 --- a/packages/core-components/src/index.ts +++ b/packages/core-components/src/index.ts @@ -7,6 +7,7 @@ export * from "./gift_cards" export * from "./in_stock_subscriptions" export * from "./line_items" export * from "./orders" +export * from "./payment_sessions" export * from "./prices" export * from "./sdk" export * from "./shipments" diff --git a/packages/core-components/src/payment_sessions/createPaymentSession.ts b/packages/core-components/src/payment_sessions/createPaymentSession.ts new file mode 100644 index 00000000..b87cb37e --- /dev/null +++ b/packages/core-components/src/payment_sessions/createPaymentSession.ts @@ -0,0 +1,35 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" + +interface CreatePaymentSessionParams extends Pick { + orderId: string + /** Id of the selected Payment Setting, from `order.available_payment_settings`. */ + paymentSettingId: string +} + +/** + * Create a Payment Session for an order against the selected Payment Setting. + * + * `amount_cents` is deliberately **not** sent. Omitting it makes the server + * size the session to the order's remaining amount, which is the only party + * that knows what that is. Sending a value is worse than redundant: the server + * silently caps it to the remaining amount, so an explicit amount can quietly + * produce a session that differs from the one requested. + * + * The relationship is set through the polymorphic `payment_settings` resource + * rather than the per-provider one, which is what the API expects for any + * setting type. + */ +export async function createPaymentSession({ + accessToken, + interceptors, + orderId, + paymentSettingId, +}: CreatePaymentSessionParams): Promise { + const sdk = getSdk({ accessToken, interceptors }) + return await sdk.payment_sessions.create({ + payment_setting: sdk.payment_settings.relationship(paymentSettingId), + order: sdk.orders.relationship(orderId), + }) +} diff --git a/packages/core-components/src/payment_sessions/findCurrentPaymentSession.spec.ts b/packages/core-components/src/payment_sessions/findCurrentPaymentSession.spec.ts new file mode 100644 index 00000000..f49fdc85 --- /dev/null +++ b/packages/core-components/src/payment_sessions/findCurrentPaymentSession.spec.ts @@ -0,0 +1,92 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { describe, expect, it } from "vitest" +import { findCurrentPaymentSession } from "./findCurrentPaymentSession" + +const MANUAL = "setting-manual" +const GIFT_CARD = "setting-gift-card" + +function session(overrides: Partial = {}): PaymentSession { + return { + id: "session-1", + type: "payment_sessions", + status: "unpaid", + payment_setting: { id: MANUAL, type: "payment_setting_manuals" }, + ...overrides, + } as PaymentSession +} + +describe("findCurrentPaymentSession", () => { + it("returns undefined when the order has no sessions", () => { + expect(findCurrentPaymentSession({ paymentSessions: [] })).toBeUndefined() + expect(findCurrentPaymentSession({ paymentSessions: null })).toBeUndefined() + }) + + // Unlike a reusable session, one that already took money is still the + // shopper's selection — it just must not be adopted for a new payment. + it.each(["unpaid", "authorized", "paid", "partially_paid"])( + "treats a session in status %s as the selection", + (status) => { + const current = session({ status }) + expect(findCurrentPaymentSession({ paymentSessions: [current] })).toBe(current) + } + ) + + // Showing a burnt session as the selection would tell the shopper a payment + // is in place when none is. + it.each(["declined", "failed", "canceled", "expired"])( + "ignores a session whose authorization is %s", + (status) => { + const burnt = session({ payment_authorization: { status } as never }) + expect(findCurrentPaymentSession({ paymentSessions: [burnt] })).toBeUndefined() + } + ) + + it("keeps a session whose authorization is still in flight", () => { + const inFlight = session({ payment_authorization: { status: "processing" } as never }) + expect(findCurrentPaymentSession({ paymentSessions: [inFlight] })).toBe(inFlight) + }) + + // Switching setting leaves the previous session behind, so "newest wins" is + // what keeps a radio group from showing two selections at once. + it("returns the most recent session when several are live", () => { + const older = session({ + id: "older", + created_at: "2026-08-18T10:00:00Z", + payment_setting: { id: GIFT_CARD } as never, + }) + const newer = session({ id: "newer", created_at: "2026-08-18T11:00:00Z" }) + expect(findCurrentPaymentSession({ paymentSessions: [older, newer] })?.id).toBe("newer") + expect(findCurrentPaymentSession({ paymentSessions: [newer, older] })?.id).toBe("newer") + }) + + it("skips burnt sessions when picking the most recent", () => { + const burntNewer = session({ + id: "burnt", + created_at: "2026-08-18T12:00:00Z", + payment_authorization: { status: "failed" } as never, + }) + const liveOlder = session({ id: "live", created_at: "2026-08-18T10:00:00Z" }) + expect(findCurrentPaymentSession({ paymentSessions: [burntNewer, liveOlder] })?.id).toBe("live") + }) + + it("falls back to array order when created_at is absent", () => { + const first = session({ id: "first" }) + const second = session({ id: "second" }) + expect(findCurrentPaymentSession({ paymentSessions: [first, second] })?.id).toBe("second") + }) + + it("narrows to one setting when asked", () => { + const manual = session({ id: "manual", created_at: "2026-08-18T10:00:00Z" }) + const giftCard = session({ + id: "gift", + created_at: "2026-08-18T11:00:00Z", + payment_setting: { id: GIFT_CARD } as never, + }) + expect( + findCurrentPaymentSession({ + paymentSessions: [manual, giftCard], + paymentSettingId: MANUAL, + })?.id + ).toBe("manual") + }) +}) diff --git a/packages/core-components/src/payment_sessions/findCurrentPaymentSession.ts b/packages/core-components/src/payment_sessions/findCurrentPaymentSession.ts new file mode 100644 index 00000000..895f3ebe --- /dev/null +++ b/packages/core-components/src/payment_sessions/findCurrentPaymentSession.ts @@ -0,0 +1,56 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { TERMINAL_FAILURE_TRANSACTION_STATUSES } from "./types" + +interface FindCurrentPaymentSessionParams { + paymentSessions?: PaymentSession[] | null + /** + * Narrow to one Payment Setting. Omit to get the order's single current + * selection across every setting, which is what a radio group needs. + */ + paymentSettingId?: string +} + +/** + * Find the Payment Session the shopper's selection points at. + * + * The order has no `payment_setting` relationship, so a selection only exists + * as a session — this is how the choice is read back, and why it survives a + * reload. Browser state is a rendering cache of this, never the authority. + * + * Distinct from {@link findReusablePaymentSession}, which answers a narrower + * question: "may I adopt this instead of creating one?". A session that has + * already taken money still *is* the selection, but must not be reused. + * + * A session carrying a failed authorization is excluded: it is burnt, the + * shopper has to try again, and showing it as the current selection would tell + * them a payment is in place when none is. + * + * **The selection is single, and it is the most recent session.** Switching + * setting leaves the previous session on the order — it is never deleted, both + * because an inert `unpaid` session costs nothing and because a sales-channel + * token can be refused the delete. Picking the newest is therefore what keeps + * a radio group coherent: without it, every setting the shopper has ever tried + * would read as selected at once. + */ +export function findCurrentPaymentSession({ + paymentSessions, + paymentSettingId, +}: FindCurrentPaymentSessionParams): PaymentSession | undefined { + const live = (paymentSessions ?? []).filter((session) => { + if (paymentSettingId != null && session.payment_setting?.id !== paymentSettingId) return false + const authorizationStatus = session.payment_authorization?.status + if (authorizationStatus == null) return true + return !TERMINAL_FAILURE_TRANSACTION_STATUSES.includes( + authorizationStatus as (typeof TERMINAL_FAILURE_TRANSACTION_STATUSES)[number] + ) + }) + + // `created_at` is optional on the type, so fall back to array order rather + // than dropping a session that came back without it. + return live.reduce((newest, session) => { + if (newest == null) return session + const a = session.created_at == null ? 0 : Date.parse(session.created_at) + const b = newest.created_at == null ? 0 : Date.parse(newest.created_at) + return a >= b ? session : newest + }, undefined) +} diff --git a/packages/core-components/src/payment_sessions/findReusablePaymentSession.spec.ts b/packages/core-components/src/payment_sessions/findReusablePaymentSession.spec.ts new file mode 100644 index 00000000..f22b2f11 --- /dev/null +++ b/packages/core-components/src/payment_sessions/findReusablePaymentSession.spec.ts @@ -0,0 +1,144 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { describe, expect, it } from "vitest" +import { findReusablePaymentSession } from "./findReusablePaymentSession" + +const NOW = new Date("2026-08-18T12:00:00Z") +const SETTING_ID = "setting-manual" + +function session(overrides: Partial = {}): PaymentSession { + return { + id: "session-1", + type: "payment_sessions", + status: "unpaid", + created_at: "", + updated_at: "", + payment_setting: { id: SETTING_ID, type: "payment_setting_manuals" }, + ...overrides, + } as PaymentSession +} + +describe("findReusablePaymentSession", () => { + it("adopts an unpaid session belonging to the selected setting", () => { + const reusable = session() + expect( + findReusablePaymentSession({ + paymentSessions: [reusable], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBe(reusable) + }) + + it("returns undefined when there are no sessions at all", () => { + expect( + findReusablePaymentSession({ paymentSessions: [], paymentSettingId: SETTING_ID, now: NOW }) + ).toBeUndefined() + expect( + findReusablePaymentSession({ paymentSessions: null, paymentSettingId: SETTING_ID, now: NOW }) + ).toBeUndefined() + }) + + it("ignores a session belonging to a different setting", () => { + const other = session({ payment_setting: { id: "setting-gift-card" } as never }) + expect( + findReusablePaymentSession({ + paymentSessions: [other], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBeUndefined() + }) + + // A session that already took money must never be adopted: `amount_cents` is + // immutable, and re-selecting it would misreport what the shopper still owes. + it.each(["authorized", "paid", "partially_paid", "voided", "refunded", "partially_refunded"])( + "ignores a session in status %s", + (status) => { + expect( + findReusablePaymentSession({ + paymentSessions: [session({ status })], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBeUndefined() + } + ) + + it("ignores an expired session", () => { + const expired = session({ expires_at: "2026-08-18T11:59:59Z" }) + expect( + findReusablePaymentSession({ + paymentSessions: [expired], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBeUndefined() + }) + + it("adopts a session whose expiry is still in the future", () => { + const live = session({ expires_at: "2026-08-18T12:00:01Z" }) + expect( + findReusablePaymentSession({ + paymentSessions: [live], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBe(live) + }) + + // The decisive case: a failed authorization leaves the session `unpaid`, + // because only a *succeeded* authorization advances it. Status alone cannot + // tell a fresh session from a burnt one. + it.each(["declined", "failed", "canceled", "expired"])( + "ignores an unpaid session whose authorization is %s", + (status) => { + const burnt = session({ payment_authorization: { status } as never }) + expect( + findReusablePaymentSession({ + paymentSessions: [burnt], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBeUndefined() + } + ) + + // In flight, not burnt — adopting it is what stops a remount creating a second. + it.each(["pending", "processing", "requires_action"])( + "adopts an unpaid session whose authorization is still %s", + (status) => { + const inFlight = session({ payment_authorization: { status } as never }) + expect( + findReusablePaymentSession({ + paymentSessions: [inFlight], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBe(inFlight) + } + ) + + it("searches the array rather than reading the first entry", () => { + const giftCard = session({ id: "gift", payment_setting: { id: "setting-gift-card" } as never }) + const burnt = session({ id: "burnt", payment_authorization: { status: "failed" } as never }) + const fresh = session({ id: "fresh" }) + expect( + findReusablePaymentSession({ + paymentSessions: [giftCard, burnt, fresh], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBe(fresh) + }) + + it("does not decide on statuses it has never heard of", () => { + const unknown = session({ status: "some_future_state" }) + expect( + findReusablePaymentSession({ + paymentSessions: [unknown], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBeUndefined() + }) +}) diff --git a/packages/core-components/src/payment_sessions/findReusablePaymentSession.ts b/packages/core-components/src/payment_sessions/findReusablePaymentSession.ts new file mode 100644 index 00000000..78dd3e67 --- /dev/null +++ b/packages/core-components/src/payment_sessions/findReusablePaymentSession.ts @@ -0,0 +1,58 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { TERMINAL_FAILURE_TRANSACTION_STATUSES } from "./types" + +interface FindReusablePaymentSessionParams { + /** The order's sessions, from `order.payment_sessions`. */ + paymentSessions?: PaymentSession[] | null + /** The Payment Setting the shopper selected. */ + paymentSettingId: string + /** Injectable for tests. Defaults to now. */ + now?: Date +} + +/** + * Find a Payment Session the library may adopt instead of creating a new one. + * + * A session qualifies when it belongs to the selected Payment Setting, has not + * taken any money yet, has not expired, and is not carrying a failed + * authorization. + * + * Reuse is not an optimisation — it is what makes selection survive a reload + * and what stops a remount from creating a second session. `amount_cents` is + * immutable once a session exists, so "update the existing one" is not an + * option the API offers. + * + * Sessions are always searched for, never read positionally: failed attempts + * leave inert `unpaid` sessions behind, and a gift card (or, later, a split + * payment) puts other settings' sessions in the same array. + */ +export function findReusablePaymentSession({ + paymentSessions, + paymentSettingId, + now = new Date(), +}: FindReusablePaymentSessionParams): PaymentSession | undefined { + return (paymentSessions ?? []).find((session) => { + if (session.payment_setting?.id !== paymentSettingId) return false + + // Anything past `unpaid` has taken money (or has been voided/refunded); + // in both cases creating or adopting it again would be wrong. + if (session.status !== "unpaid") return false + + if (session.expires_at != null && new Date(session.expires_at) <= now) return false + + // A session whose authorization failed stays `unpaid` forever — the + // session only advances on a *succeeded* authorization — so status alone + // cannot tell a fresh session from a burnt one. + const authorizationStatus = session.payment_authorization?.status + if ( + authorizationStatus != null && + TERMINAL_FAILURE_TRANSACTION_STATUSES.includes( + authorizationStatus as (typeof TERMINAL_FAILURE_TRANSACTION_STATUSES)[number] + ) + ) { + return false + } + + return true + }) +} diff --git a/packages/core-components/src/payment_sessions/index.ts b/packages/core-components/src/payment_sessions/index.ts new file mode 100644 index 00000000..f784a690 --- /dev/null +++ b/packages/core-components/src/payment_sessions/index.ts @@ -0,0 +1,21 @@ +export { createPaymentSession } from "./createPaymentSession" +export { findCurrentPaymentSession } from "./findCurrentPaymentSession" +export { findReusablePaymentSession } from "./findReusablePaymentSession" +export { mapPlaceabilityErrors } from "./mapPlaceabilityErrors" +export type { PlaceOrderWithPaymentSessionsResult } from "./placeOrderWithPaymentSessions" +export { + DEFAULT_PLACEABLE_ATTEMPTS, + DEFAULT_PLACEABLE_INTERVAL_MS, + placeOrderWithPaymentSessions, +} from "./placeOrderWithPaymentSessions" +export type { + KnownPaymentSessionStatus, + KnownPaymentTransactionStatus, + PaymentSessionStatus, + PaymentTransactionStatus, + PlaceabilityError, +} from "./types" +export { + PAYMENT_TAKEN_SESSION_STATUSES, + TERMINAL_FAILURE_TRANSACTION_STATUSES, +} from "./types" diff --git a/packages/core-components/src/payment_sessions/mapPlaceabilityErrors.ts b/packages/core-components/src/payment_sessions/mapPlaceabilityErrors.ts new file mode 100644 index 00000000..132ce830 --- /dev/null +++ b/packages/core-components/src/payment_sessions/mapPlaceabilityErrors.ts @@ -0,0 +1,66 @@ +import type { PlaceabilityError } from "./types" + +/** Shape of a JSON:API error object as the API sends it. */ +interface ApiErrorObject { + code?: string + title?: string + detail?: string + source?: { pointer?: string } + meta?: { error?: string } +} + +/** + * Turn a 422 response from the `_placeable` trigger into one error per reason. + * + * The API answers a refused placement with a JSON:API `errors` array where each + * entry points at the attribute that failed: + * + * ```json + * { "code": "VALIDATION_ERROR", + * "detail": "Your order couldn't be placed because ...", + * "source": { "pointer": "/data/attributes/payment_action" }, + * "meta": { "error": "..." } } + * ``` + * + * One error per reason, rather than one concatenated message, so a consumer can + * address each individually instead of parsing prose. Populating `field` also + * keeps the existing non-blocking-error filter working, which exempts coupon + * and gift-card fields from blocking the place-order button. + * + * Be aware that *every* payment-rule failure — the order not being covered, a + * payment setting not being allowed — arrives as `field: "payment_action"`. + * They can only be told apart by their message. + */ +export function mapPlaceabilityErrors(error: unknown): PlaceabilityError[] { + const errors = extractErrorArray(error) + if (errors.length === 0) return [] + + return errors.map((apiError) => ({ + code: apiError.code ?? "VALIDATION_ERROR", + message: apiError.detail ?? apiError.title ?? "The order cannot be placed.", + field: fieldFromPointer(apiError.source?.pointer), + ...(apiError.meta?.error != null ? { meta: { error: apiError.meta.error } } : {}), + })) +} + +/** + * `/data/attributes/payment_action` → `payment_action`. + * `/data` (a base error, not tied to an attribute) → `base`. + */ +function fieldFromPointer(pointer?: string): string | undefined { + if (pointer == null || pointer === "") return undefined + const last = pointer.split("/").pop() + if (last == null || last === "") return undefined + return last === "data" ? "base" : last +} + +/** + * The SDK surfaces API errors as a thrown object, but the exact wrapper has + * moved between versions, so probe the two shapes rather than assuming one. + */ +function extractErrorArray(error: unknown): ApiErrorObject[] { + if (error == null || typeof error !== "object") return [] + const candidate = error as { errors?: unknown; response?: { data?: { errors?: unknown } } } + const errors = candidate.errors ?? candidate.response?.data?.errors + return Array.isArray(errors) ? (errors as ApiErrorObject[]) : [] +} diff --git a/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.spec.ts b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.spec.ts new file mode 100644 index 00000000..cd937a9c --- /dev/null +++ b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.spec.ts @@ -0,0 +1,248 @@ +import type { Order, PaymentSession } from "@commercelayer/sdk" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { placeOrderWithPaymentSessions } from "./placeOrderWithPaymentSessions" + +const { getSdkMock } = vi.hoisted(() => ({ getSdkMock: vi.fn() })) +vi.mock("#sdk", () => ({ getSdk: getSdkMock })) + +const ORDER_ID = "order-1" +const ACCESS_TOKEN = "token" + +function order(status: Order["status"]): Order { + return { id: ORDER_ID, type: "orders", status } as Order +} + +/** A 422 from the `_placeable` trigger, as the API shapes it. */ +function placeabilityRefusal(detail = "The payment doesn't cover the order.") { + return { + errors: [ + { + code: "VALIDATION_ERROR", + detail, + source: { pointer: "/data/attributes/payment_action" }, + meta: { error: "payment_action" }, + }, + ], + } +} + +function session(overrides: Partial = {}): PaymentSession { + return { + id: "session-1", + type: "payment_sessions", + status: "unpaid", + ...overrides, + } as PaymentSession +} + +interface SdkStub { + _placeable: ReturnType + _place: ReturnType + createAuthorization: ReturnType +} + +function stubSdk(): SdkStub { + const stub: SdkStub = { + _placeable: vi.fn(), + _place: vi.fn().mockResolvedValue(order("placed")), + createAuthorization: vi.fn().mockResolvedValue({ id: "auth-1" }), + } + getSdkMock.mockReturnValue({ + orders: { _placeable: stub._placeable, _place: stub._place, relationship: vi.fn() }, + payment_authorizations: { create: stub.createAuthorization }, + payment_sessions: { relationship: vi.fn((id: string) => ({ id, type: "payment_sessions" })) }, + }) + return stub +} + +// intervalMs: 0 keeps the retry loop synchronous. The delay is the thing under +// test only in "waits between attempts", where it is asserted separately. +const NO_WAIT = { attempts: 5, intervalMs: 0 } + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("placeOrderWithPaymentSessions", () => { + it("authorizes, checks placeability, then places", async () => { + const sdk = stubSdk() + sdk._placeable.mockResolvedValue(order("pending")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + orderId: ORDER_ID, + paymentSession: session(), + ...NO_WAIT, + }) + + expect(sdk.createAuthorization).toHaveBeenCalledWith({ + payment_session: { id: "session-1", type: "payment_sessions" }, + }) + expect(sdk._placeable).toHaveBeenCalledWith(ORDER_ID) + expect(sdk._place).toHaveBeenCalledWith(ORDER_ID) + expect(result).toMatchObject({ placed: true, errors: [], timedOut: false }) + }) + + // auto_place on the Payment Setting places the order inside the + // authorization job, so it can be placed before we ever look. + it("skips _place when the order was already placed by auto_place", async () => { + const sdk = stubSdk() + sdk._placeable.mockResolvedValue(order("placed")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + orderId: ORDER_ID, + paymentSession: session(), + ...NO_WAIT, + }) + + expect(sdk._place).not.toHaveBeenCalled() + expect(result).toMatchObject({ placed: true, timedOut: false }) + }) + + // The whole point of the loop: authorizing is asynchronous, so an early + // refusal is not a real failure. + it("retries a placeability refusal and succeeds once the authorization lands", async () => { + const sdk = stubSdk() + sdk._placeable + .mockRejectedValueOnce(placeabilityRefusal()) + .mockRejectedValueOnce(placeabilityRefusal()) + .mockResolvedValue(order("pending")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + orderId: ORDER_ID, + paymentSession: session(), + ...NO_WAIT, + }) + + expect(sdk._placeable).toHaveBeenCalledTimes(3) + expect(result).toMatchObject({ placed: true, errors: [], timedOut: false }) + }) + + it("reports the last refusal once the attempts run out", async () => { + const sdk = stubSdk() + sdk._placeable.mockRejectedValue(placeabilityRefusal("Still not covered.")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + orderId: ORDER_ID, + paymentSession: session(), + attempts: 3, + intervalMs: 0, + }) + + expect(sdk._placeable).toHaveBeenCalledTimes(3) + expect(sdk._place).not.toHaveBeenCalled() + expect(result.placed).toBe(false) + expect(result.timedOut).toBe(true) + expect(result.errors).toEqual([ + { + code: "VALIDATION_ERROR", + message: "Still not covered.", + field: "payment_action", + meta: { error: "payment_action" }, + }, + ]) + }) + + it("waits between attempts", async () => { + const sdk = stubSdk() + sdk._placeable.mockRejectedValue(placeabilityRefusal()) + const started = Date.now() + + await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + orderId: ORDER_ID, + paymentSession: session(), + attempts: 3, + intervalMs: 20, + }) + + // Two gaps between three attempts, and no trailing wait after the last one. + expect(Date.now() - started).toBeGreaterThanOrEqual(40) + }) + + // Waiting cannot fix a 401 or a network failure, so burning five attempts on + // it only delays the report. + it("rethrows an error that is not a placeability refusal", async () => { + const sdk = stubSdk() + sdk._placeable.mockRejectedValue(new Error("Unauthorized")) + + await expect( + placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + orderId: ORDER_ID, + paymentSession: session(), + ...NO_WAIT, + }) + ).rejects.toThrow("Unauthorized") + expect(sdk._placeable).toHaveBeenCalledTimes(1) + }) + + describe("authorization", () => { + it("creates one when the session has none", async () => { + const sdk = stubSdk() + sdk._placeable.mockResolvedValue(order("pending")) + + await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + orderId: ORDER_ID, + paymentSession: session(), + ...NO_WAIT, + }) + + expect(sdk.createAuthorization).toHaveBeenCalledOnce() + }) + + // Creating a second authorization over one still in flight risks taking + // the money twice. + it.each(["pending", "processing", "requires_action", "succeeded"])( + "does not create a second one when the existing authorization is %s", + async (status) => { + const sdk = stubSdk() + sdk._placeable.mockResolvedValue(order("pending")) + + await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + orderId: ORDER_ID, + paymentSession: session({ payment_authorization: { status } as never }), + ...NO_WAIT, + }) + + expect(sdk.createAuthorization).not.toHaveBeenCalled() + } + ) + + it.each(["declined", "failed", "canceled", "expired"])( + "creates a new one when the existing authorization is %s", + async (status) => { + const sdk = stubSdk() + sdk._placeable.mockResolvedValue(order("pending")) + + await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + orderId: ORDER_ID, + paymentSession: session({ payment_authorization: { status } as never }), + ...NO_WAIT, + }) + + expect(sdk.createAuthorization).toHaveBeenCalledOnce() + } + ) + + it("skips authorization entirely for an order with no session", async () => { + const sdk = stubSdk() + sdk._placeable.mockResolvedValue(order("pending")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + orderId: ORDER_ID, + ...NO_WAIT, + }) + + expect(sdk.createAuthorization).not.toHaveBeenCalled() + expect(result.placed).toBe(true) + }) + }) +}) diff --git a/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts new file mode 100644 index 00000000..9512fbcb --- /dev/null +++ b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts @@ -0,0 +1,130 @@ +import type { Order, PaymentSession } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" +import { mapPlaceabilityErrors } from "./mapPlaceabilityErrors" +import { type PlaceabilityError, TERMINAL_FAILURE_TRANSACTION_STATUSES } from "./types" + +/** Attempts before the placeability check gives up. */ +export const DEFAULT_PLACEABLE_ATTEMPTS = 5 +/** Delay between placeability attempts, in milliseconds. */ +export const DEFAULT_PLACEABLE_INTERVAL_MS = 1000 + +interface PlaceOrderWithPaymentSessionsParams + extends Pick { + orderId: string + /** + * The Current Payment Session to authorize. Omit for an order that needs no + * payment (a zero-total order), which goes straight to the placeability + * check. + */ + paymentSession?: PaymentSession | null + /** Placeability attempts before giving up. Defaults to 5. */ + attempts?: number + /** Delay between attempts, in milliseconds. Defaults to 1000. */ + intervalMs?: number +} + +export interface PlaceOrderWithPaymentSessionsResult { + /** True when the order is placed — whether by us or by `auto_place`. */ + placed: boolean + /** The order as the API last returned it. */ + order?: Order + /** Reasons the order could not be placed, from the final failed attempt. */ + errors: PlaceabilityError[] + /** + * True when the attempts ran out. The payment may still succeed later, so + * this must never be reported as a payment failure — see below. + */ + timedOut: boolean +} + +/** + * Take payment for an order on the `payment_sessions` model and place it. + * + * The sequence is: create the Payment Authorization, poll `_placeable` until + * the API accepts, then `_place`. + * + * **Why the authorization is created here and not at selection.** An + * authorization is the record proving money was taken. Creating it when the + * shopper picks a radio button would take their money on selection and make + * changing their mind a cascade delete of an accounting record. Creating it + * here keeps selection free and reversible. + * + * **Why `_placeable` is retried instead of reported.** Authorizing is + * asynchronous — for a manual setting it runs in a background job — so the + * first check legitimately fails with "the payment doesn't cover the required + * percentage" while the money is still being taken. Reporting that straight + * away would tell the shopper their payment failed a second before it + * succeeded. Only an error that survives the last attempt is real. + * + * A failed `_placeable` persists nothing (the trigger is a validation, and a + * failed validation is not saved), so retrying it is cheap and side-effect + * free despite the PATCH verb. + * + * **There is no client-side coverage gate.** Coverage is enforced by a payment + * rule whose threshold an organization can change — it can be lowered to accept + * part payments — so only the server knows what "covered" means for this order. + */ +export async function placeOrderWithPaymentSessions({ + accessToken, + interceptors, + orderId, + paymentSession, + attempts = DEFAULT_PLACEABLE_ATTEMPTS, + intervalMs = DEFAULT_PLACEABLE_INTERVAL_MS, +}: PlaceOrderWithPaymentSessionsParams): Promise { + const sdk = getSdk({ accessToken, interceptors }) + + if (paymentSession != null && needsAuthorization(paymentSession)) { + await sdk.payment_authorizations.create({ + payment_session: sdk.payment_sessions.relationship(paymentSession.id), + }) + } + + let lastErrors: PlaceabilityError[] = [] + + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const checked = await sdk.orders._placeable(orderId) + + // `auto_place` on the Payment Setting places the order inside the + // authorization job, so it can already be placed by the time we look. + // `_placeable` returns 200 for a placed order — `ensure_pending` only + // promotes drafts, it does not reject anything else — so this is a + // success, not a race to recover from. + if (checked.status === "placed") { + return { placed: true, order: checked, errors: [], timedOut: false } + } + + const placed = await sdk.orders._place(orderId) + return { placed: placed.status === "placed", order: placed, errors: [], timedOut: false } + } catch (error) { + lastErrors = mapPlaceabilityErrors(error) + // An error we cannot read as a placeability refusal is not something + // waiting will fix — surface it instead of burning the attempts. + if (lastErrors.length === 0) throw error + if (attempt < attempts) await sleep(intervalMs) + } + } + + return { placed: false, errors: lastErrors, timedOut: true } +} + +/** + * Skip the authorization when the session already has one that is still alive. + * Only a terminal failure means the shopper is genuinely retrying; a `pending` + * or `processing` authorization is still in flight, and creating a second one + * risks taking the money twice. + */ +function needsAuthorization(paymentSession: PaymentSession): boolean { + const status = paymentSession.payment_authorization?.status + if (status == null) return true + return TERMINAL_FAILURE_TRANSACTION_STATUSES.includes( + status as (typeof TERMINAL_FAILURE_TRANSACTION_STATUSES)[number] + ) +} + +async function sleep(ms: number): Promise { + if (ms <= 0) return + await new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/packages/core-components/src/payment_sessions/types.ts b/packages/core-components/src/payment_sessions/types.ts new file mode 100644 index 00000000..1be67208 --- /dev/null +++ b/packages/core-components/src/payment_sessions/types.ts @@ -0,0 +1,91 @@ +/** + * Status values for the `payment_sessions` payment model. + * + * The SDK types every one of these as a bare `string`, so these unions are + * transcribed by hand from the AASM state machines in `core-api`. Each is + * widened with `(string & {})` so an unknown value stays assignable: the + * server can add a state without breaking consumers, and every branch that + * decides on a status is required to have a `default`. + * + * **Re-check these against `core-api` whenever the SDK is upgraded.** + */ + +/** Any value the API may send, while still autocompleting the known ones. */ +type Widen = T | (string & {}) + +/** + * `app/models/payment_session.rb:21-28` — initial state is `unpaid`. + * Note there are no per-state timestamp columns on payment sessions, unlike + * transactions, so `status` is the only way to read a session's state. + */ +export type KnownPaymentSessionStatus = + | "unpaid" + | "authorized" + | "voided" + | "paid" + | "partially_paid" + | "refunded" + | "partially_refunded" + +export type PaymentSessionStatus = Widen + +/** + * The subset of session states that count toward the order's paid amount. + * `app/models/payment_session.rb:15` (`PAYMENT_TAKEN_STATES`). + */ +export const PAYMENT_TAKEN_SESSION_STATUSES = [ + "authorized", + "paid", + "partially_paid", +] as const satisfies readonly KnownPaymentSessionStatus[] + +/** + * `app/models/payment_transaction.rb:22-31` — initial state is `pending`. + * Shared by all four STI subclasses: payment authorizations, captures, voids + * and refunds run the same machine. + */ +export type KnownPaymentTransactionStatus = + | "pending" + | "requires_action" + | "processing" + | "succeeded" + | "declined" + | "failed" + | "canceled" + | "expired" + +export type PaymentTransactionStatus = Widen + +/** + * Transaction states a session cannot recover from. A session carrying an + * authorization in one of these is burnt: it stays `unpaid` forever, because + * only a `succeeded` authorization transitions the session. + */ +export const TERMINAL_FAILURE_TRANSACTION_STATUSES = [ + "declined", + "failed", + "canceled", + "expired", +] as const satisfies readonly KnownPaymentTransactionStatus[] + +/** + * One reason the API gave for refusing to place an order, mapped out of a 422 + * JSON:API error object. + * + * Note that every payment-rule failure — insufficient coverage, a payment + * setting the order is not allowed to use — arrives with the same + * `field: "payment_action"`, distinguishable only by `message`. + */ +export interface PlaceabilityError { + /** JSON:API error `code`, e.g. `"VALIDATION_ERROR"`. */ + code: string + /** Human-readable reason. For payment rules this is the rule's message. */ + message: string + /** + * Last segment of `source.pointer`, so `/data/attributes/payment_action` + * becomes `"payment_action"`. Base errors (`/data`) become `"base"`. + */ + field?: string + /** Symbolic reason from `meta.error`, when the API sends one. */ + meta?: { error: string } +} diff --git a/packages/react-components/specs/gift_cards/GiftCardOrCouponForm.paymentsModel.spec.tsx b/packages/react-components/specs/gift_cards/GiftCardOrCouponForm.paymentsModel.spec.tsx new file mode 100644 index 00000000..8f09d2bd --- /dev/null +++ b/packages/react-components/specs/gift_cards/GiftCardOrCouponForm.paymentsModel.spec.tsx @@ -0,0 +1,67 @@ +import type { Order } from "@commercelayer/sdk" +import { render, screen } from "@testing-library/react" +import type { ReactNode } from "react" +import { describe, expect, it, vi } from "vitest" +import GiftCardOrCouponForm from "#components/gift_cards/GiftCardOrCouponForm" +import GiftCardOrCouponInput from "#components/gift_cards/GiftCardOrCouponInput" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" + +vi.mock("rapid-form", () => ({ + useRapidForm: () => ({ refValidation: vi.fn(), values: {} }), +})) + +const MANUAL = { id: "ps-manual", type: "payment_setting_manuals" } + +function Wrapper({ children, order }: { children: ReactNode; order: Partial }) { + return ( + + {children} + + ) +} + +function renderForm(order: Partial, codeType?: "gift_card_code" | "coupon_code") { + return render( + + + + + + ) +} + +const inputName = () => (screen.getByTestId("input") as HTMLInputElement).name + +describe("GiftCardOrCouponForm on the payment_sessions model", () => { + // On this model a gift card is spent by creating a Payment Session against a + // gift-card Payment Setting, so it belongs among the payment methods. The + // order-level code field would apply a gift card no session reflects. + it("offers only the coupon", () => { + renderForm({ id: "order-1", available_payment_settings: [MANUAL] } as never) + expect(inputName()).toBe("coupon_code") + }) + + it("overrides an explicit gift_card_code request", () => { + renderForm({ id: "order-1", available_payment_settings: [MANUAL] } as never, "gift_card_code") + expect(inputName()).toBe("coupon_code") + }) + + // A leftover gift card code from the older model must not hide the coupon + // form, since the requested type is no longer what gets rendered. + it("still renders when the order carries a stale gift card code", () => { + renderForm( + { + id: "order-1", + available_payment_settings: [MANUAL], + gift_card_code: "OLD-CARD", + } as never, + "gift_card_code" + ) + expect(inputName()).toBe("coupon_code") + }) + + it("leaves the payment_source model untouched", () => { + renderForm({ id: "order-1", available_payment_methods: [{ id: "pm-1" }] } as never) + expect(inputName()).toBe("gift_card_or_coupon_code") + }) +}) diff --git a/packages/react-components/specs/hooks/usePaymentsModel.spec.tsx b/packages/react-components/specs/hooks/usePaymentsModel.spec.tsx new file mode 100644 index 00000000..83899080 --- /dev/null +++ b/packages/react-components/specs/hooks/usePaymentsModel.spec.tsx @@ -0,0 +1,71 @@ +import type { Order } from "@commercelayer/sdk" +import { renderHook } from "@testing-library/react" +import type { ReactNode } from "react" +import { describe, expect, it } from "vitest" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import { usePaymentsModel } from "#hooks/usePaymentsModel" + +function wrapper(order?: Partial | null) { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +const SETTING = { id: "ps-1", type: "payment_setting_manuals" } +const METHOD = { id: "pm-1", payment_source_type: "stripe_payments" } + +describe("usePaymentsModel", () => { + it("is undetermined until the order has loaded", () => { + const { result } = renderHook(() => usePaymentsModel(), { wrapper: wrapper(null) }) + expect(result.current).toBe("undetermined") + }) + + it("reads payment_sessions from available_payment_settings", () => { + const { result } = renderHook(() => usePaymentsModel(), { + wrapper: wrapper({ id: "order-1", available_payment_settings: [SETTING] } as never), + }) + expect(result.current).toBe("payment_sessions") + }) + + it("reads payment_source from available_payment_methods", () => { + const { result } = renderHook(() => usePaymentsModel(), { + wrapper: wrapper({ id: "order-1", available_payment_methods: [METHOD] } as never), + }) + expect(result.current).toBe("payment_source") + }) + + // API version 2026-05 is additive, so both arrays can arrive together. The + // newer model wins, and the precedence lives here rather than in the app. + it("prefers payment_sessions when the order carries both", () => { + const { result } = renderHook(() => usePaymentsModel(), { + wrapper: wrapper({ + id: "order-1", + available_payment_settings: [SETTING], + available_payment_methods: [METHOD], + } as never), + }) + expect(result.current).toBe("payment_sessions") + }) + + // An empty array is not a model: an order that simply has nothing configured + // must not be routed to either tree. + it("is undetermined when both arrays are empty", () => { + const { result } = renderHook(() => usePaymentsModel(), { + wrapper: wrapper({ + id: "order-1", + available_payment_settings: [], + available_payment_methods: [], + } as never), + }) + expect(result.current).toBe("undetermined") + }) + + it("is undetermined when the order carries neither relationship", () => { + const { result } = renderHook(() => usePaymentsModel(), { + wrapper: wrapper({ id: "order-1" } as never), + }) + expect(result.current).toBe("undetermined") + }) +}) diff --git a/packages/react-components/specs/orders/place-order-payments-model.spec.tsx b/packages/react-components/specs/orders/place-order-payments-model.spec.tsx new file mode 100644 index 00000000..fa04d2ee --- /dev/null +++ b/packages/react-components/specs/orders/place-order-payments-model.spec.tsx @@ -0,0 +1,240 @@ +import type { Order } from "@commercelayer/sdk" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import type { ReactNode } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { PlaceOrderButton } from "#components/orders/PlaceOrderButton" +import { PlaceOrderButtonPaymentSessions } from "#components/orders/PlaceOrderButtonPaymentSessions" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" + +const { placeOrderMock } = vi.hoisted(() => ({ placeOrderMock: vi.fn() })) + +vi.mock("@commercelayer/core-components", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, placeOrderWithPaymentSessions: placeOrderMock } +}) + +// The older branch drags in gateway SDKs and organization config; this suite is +// about routing and the new branch, so keep it out of the way. +vi.mock("#components/orders/PlaceOrderButtonPaymentSource", () => ({ + PlaceOrderButtonPaymentSource: () => , +})) +vi.mock("#utils/organization", () => ({ useOrganizationConfig: () => ({ urls: {} }) })) + +const MANUAL = { id: "ps-manual", type: "payment_setting_manuals" } + +function orderOnSessions(overrides: Partial = {}): Partial { + return { + id: "order-1", + status: "pending", + available_payment_settings: [MANUAL], + payment_sessions: [{ id: "session-1", status: "unpaid", payment_setting: MANUAL }], + ...overrides, + } as Partial +} + +const setOrderErrors = vi.fn() +const getOrder = vi.fn() + +function Wrapper({ + children, + currentOrder, +}: { + children: ReactNode + currentOrder?: Partial | null +}) { + return ( + + + {children} + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + placeOrderMock.mockResolvedValue({ placed: true, order: { status: "placed" }, errors: [] }) +}) + +describe("PlaceOrderButton routing", () => { + it("renders the payment_source branch for an order on that model", () => { + render( + + + + ) + expect(screen.getByRole("button").textContent).toBe("payment_source branch") + }) + + it("renders the payment_sessions branch for an order on that model", () => { + render( + + + + ) + expect(screen.getByRole("button").textContent).toBe("Pay now") + }) + + // Neither branch may run before the model is known: mounting the older one + // would start redirect effects reading a payment source this order has not + // got, and rendering nothing would make the button appear late — a visible + // change for applications that only ever mounted . + it("renders an inert button while the model is undetermined", () => { + render( + + + + ) + const button = screen.getByRole("button") as HTMLButtonElement + expect(button.textContent).toBe("Pay now") + expect(button.disabled).toBe(true) + }) + + it("does not place anything while the model is undetermined", async () => { + render( + + + + ) + await act(async () => { + fireEvent.click(screen.getByRole("button")) + }) + expect(placeOrderMock).not.toHaveBeenCalled() + }) +}) + +describe("PlaceOrderButtonPaymentSessions", () => { + function renderButton(currentOrder: Partial | null = orderOnSessions(), props = {}) { + return render( + + + + ) + } + + // Placeability cannot be read before clicking: `order.placeable` is never + // served on a GET, and it stays false while the asynchronous authorization + // is still in flight. Disabling on it would block the button exactly when + // payment is under way. + it("stays enabled without waiting for placeability", () => { + renderButton() + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(false) + }) + + it("places the order with the current session", async () => { + const onClick = vi.fn() + renderButton(orderOnSessions(), { onClick }) + + await act(async () => { + fireEvent.click(screen.getByRole("button")) + }) + + await waitFor(() => { + expect(placeOrderMock).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: "order-1", + paymentSession: expect.objectContaining({ id: "session-1" }), + }) + ) + }) + expect(onClick).toHaveBeenCalledWith(expect.objectContaining({ placed: true })) + }) + + it("surfaces placeability reasons as one error each", async () => { + placeOrderMock.mockResolvedValue({ + placed: false, + timedOut: true, + errors: [ + { + code: "VALIDATION_ERROR", + message: "Payment does not cover the order.", + field: "payment_action", + }, + { + code: "VALIDATION_ERROR", + message: "Billing address is missing.", + field: "billing_address", + }, + ], + }) + const onClick = vi.fn() + renderButton(orderOnSessions(), { onClick }) + + await act(async () => { + fireEvent.click(screen.getByRole("button")) + }) + + await waitFor(() => { + expect(setOrderErrors).toHaveBeenCalled() + }) + const errors = setOrderErrors.mock.calls.at(-1)?.[0] + expect(errors).toHaveLength(2) + expect(errors[0]).toMatchObject({ field: "payment_action", resource: "orders" }) + expect(errors[1]).toMatchObject({ field: "billing_address" }) + expect(onClick).toHaveBeenCalledWith(expect.objectContaining({ placed: false })) + // The order moved on without us, so stale amounts must not stay on screen. + expect(getOrder).toHaveBeenCalledWith("order-1") + }) + + it("reports a thrown error without claiming the order was placed", async () => { + placeOrderMock.mockRejectedValue(new Error("Unauthorized")) + const onClick = vi.fn() + renderButton(orderOnSessions(), { onClick }) + + await act(async () => { + fireEvent.click(screen.getByRole("button")) + }) + + await waitFor(() => { + expect(onClick).toHaveBeenCalledWith( + expect.objectContaining({ + placed: false, + errors: [expect.objectContaining({ message: "Unauthorized" })], + }) + ) + }) + }) + + describe("privacy and terms", () => { + // A legal requirement of the checkout, not a property of the payment model, + // so it gates this branch exactly as it gates the older one. + it("blocks the button when both URLs are configured and the box is unchecked", () => { + renderButton( + orderOnSessions({ + privacy_url: "https://example.com/privacy", + terms_url: "https://example.com/terms", + } as never) + ) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true) + }) + + it("allows the button once the box is checked", () => { + localStorage.setItem("privacy-terms", "true") + renderButton( + orderOnSessions({ + privacy_url: "https://example.com/privacy", + terms_url: "https://example.com/terms", + } as never) + ) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(false) + }) + + it("does not gate an order with no privacy and terms URLs", () => { + renderButton() + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(false) + }) + }) +}) diff --git a/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx b/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx new file mode 100644 index 00000000..d4400601 --- /dev/null +++ b/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx @@ -0,0 +1,245 @@ +import type { Order } from "@commercelayer/sdk" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import type { ReactNode } from "react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { PaymentSetting } from "#components/payment_settings/PaymentSetting" +import { PaymentSettingManualPayment } from "#components/payment_settings/PaymentSettingManualPayment" +import { PaymentSettingName } from "#components/payment_settings/PaymentSettingName" +import { PaymentSettingRadioButton } from "#components/payment_settings/PaymentSettingRadioButton" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" + +const { createPaymentSessionMock } = vi.hoisted(() => ({ createPaymentSessionMock: vi.fn() })) + +vi.mock("@commercelayer/core-components", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, createPaymentSession: createPaymentSessionMock } +}) + +const MANUAL = { id: "ps-manual", type: "payment_setting_manuals", name: "Bank transfer" } +const STRIPE = { id: "ps-stripe", type: "payment_setting_stripes", name: "Stripe" } + +function order(overrides: Partial = {}): Partial { + return { + id: "order-1", + available_payment_settings: [MANUAL], + payment_sessions: [], + ...overrides, + } as Partial +} + +const getOrder = vi.fn() +const addResourceToInclude = vi.fn() + +function Wrapper({ + children, + currentOrder, +}: { + children: ReactNode + currentOrder?: Partial | null +}) { + return ( + + + {children} + + + ) +} + +function renderSettings(currentOrder?: Partial | null) { + return render( + + + + + IBAN} /> + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() + createPaymentSessionMock.mockResolvedValue({ id: "session-new" }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe("PaymentSetting", () => { + it("renders a setting from available_payment_settings", () => { + renderSettings(order()) + expect(screen.getByTestId("name").textContent).toBe("Bank transfer") + expect(screen.getByTestId("radio")).toBeTruthy() + }) + + // Self-silencing is what lets both payment trees be mounted side by side + // without a coordinator above them. + it("renders nothing when the order is on the payment_source model", () => { + renderSettings( + order({ + available_payment_settings: [], + available_payment_methods: [{ id: "pm-1" }], + } as never) + ) + expect(screen.queryByTestId("radio")).toBeNull() + }) + + it("renders nothing before the order has loaded", () => { + renderSettings(null) + expect(screen.queryByTestId("radio")).toBeNull() + }) + + // A radio for a setting with no implementation behind it does nothing when + // clicked, which is worse for the shopper than not offering it. + it("skips settings it cannot drive yet", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined) + renderSettings(order({ available_payment_settings: [MANUAL, STRIPE] } as never)) + expect(screen.getAllByTestId("name")).toHaveLength(1) + expect(screen.getByTestId("name").textContent).toBe("Bank transfer") + expect(warn).toHaveBeenCalledWith(expect.stringContaining("payment_setting_stripes")) + }) + + describe("selection", () => { + it("creates a Payment Session when the setting is chosen", async () => { + renderSettings(order()) + await act(async () => { + fireEvent.click(screen.getByTestId("radio")) + }) + + await waitFor(() => { + expect(createPaymentSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ orderId: "order-1", paymentSettingId: "ps-manual" }) + ) + }) + // The order is the only source of truth for the selection, so it has to + // be pulled back in before anything reflects the new session. + expect(getOrder).toHaveBeenCalledWith("order-1") + }) + + it("never sends amount_cents — the server sizes the session", async () => { + renderSettings(order()) + await act(async () => { + fireEvent.click(screen.getByTestId("radio")) + }) + await waitFor(() => { + expect(createPaymentSessionMock).toHaveBeenCalled() + }) + expect(createPaymentSessionMock.mock.calls[0]?.[0]).not.toHaveProperty("amount_cents") + }) + + // Switching setting leaves the previous session on the order — it is never + // deleted — so the newest one is what the radio group must follow. + // Otherwise every setting the shopper ever tried reads as selected at once. + it("follows the most recent session when the order carries several", () => { + renderSettings( + order({ + payment_sessions: [ + { + id: "session-manual", + status: "unpaid", + created_at: "2026-08-18T10:00:00Z", + payment_setting: MANUAL, + }, + { + id: "session-other", + status: "unpaid", + created_at: "2026-08-18T11:00:00Z", + payment_setting: STRIPE, + }, + ], + } as never) + ) + expect((screen.getByTestId("radio") as HTMLInputElement).checked).toBe(false) + }) + + it("selects the setting whose session is the most recent", () => { + renderSettings( + order({ + payment_sessions: [ + { + id: "session-other", + status: "unpaid", + created_at: "2026-08-18T10:00:00Z", + payment_setting: STRIPE, + }, + { + id: "session-manual", + status: "unpaid", + created_at: "2026-08-18T11:00:00Z", + payment_setting: MANUAL, + }, + ], + } as never) + ) + expect((screen.getByTestId("radio") as HTMLInputElement).checked).toBe(true) + }) + + // A failed authorization leaves the session `unpaid`, so status alone + // cannot tell a fresh session from a burnt one. + it("creates a new session when the existing one carries a failed authorization", async () => { + renderSettings( + order({ + payment_sessions: [ + { + id: "session-1", + status: "unpaid", + payment_setting: MANUAL, + payment_authorization: { status: "failed" }, + }, + ], + } as never) + ) + await act(async () => { + fireEvent.click(screen.getByTestId("radio")) + }) + await waitFor(() => { + expect(createPaymentSessionMock).toHaveBeenCalledOnce() + }) + }) + + it("reads the selection back from the order, not from local state", () => { + renderSettings( + order({ + payment_sessions: [{ id: "session-1", status: "unpaid", payment_setting: MANUAL }], + } as never) + ) + expect((screen.getByTestId("radio") as HTMLInputElement).checked).toBe(true) + expect(screen.getByTestId("instructions")).toBeTruthy() + }) + + it("does not show the setting as chosen while no session exists", () => { + renderSettings(order()) + expect((screen.getByTestId("radio") as HTMLInputElement).checked).toBe(false) + expect(screen.queryByTestId("instructions")).toBeNull() + }) + + it("ignores a click on the setting already chosen", async () => { + renderSettings( + order({ + payment_sessions: [{ id: "session-1", status: "unpaid", payment_setting: MANUAL }], + } as never) + ) + await act(async () => { + fireEvent.click(screen.getByTestId("radio")) + }) + expect(createPaymentSessionMock).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/react-components/specs/reducers/OrderReducer.includeRequest.spec.ts b/packages/react-components/specs/reducers/OrderReducer.includeRequest.spec.ts index 6b466118..a5f2658c 100644 --- a/packages/react-components/specs/reducers/OrderReducer.includeRequest.spec.ts +++ b/packages/react-components/specs/reducers/OrderReducer.includeRequest.spec.ts @@ -10,9 +10,11 @@ vi.mock("@commercelayer/core-components", async (importOriginal) => { } }) -const { getApiOrder, addResourceToInclude, default: orderReducer } = await import( - "#reducers/OrderReducer" -) +const { + getApiOrder, + addResourceToInclude, + default: orderReducer, +} = await import("#reducers/OrderReducer") type OrderActions = import("#reducers/OrderReducer").OrderActions type OrderState = import("#reducers/OrderReducer").OrderState diff --git a/packages/react-components/src/components/gift_cards/GiftCardOrCouponCode.tsx b/packages/react-components/src/components/gift_cards/GiftCardOrCouponCode.tsx index 0a0ff14e..165aec2d 100644 --- a/packages/react-components/src/components/gift_cards/GiftCardOrCouponCode.tsx +++ b/packages/react-components/src/components/gift_cards/GiftCardOrCouponCode.tsx @@ -1,6 +1,7 @@ import { type JSX, useContext } from "react" import Parent from "#components/utils/Parent" import OrderContext from "#context/OrderContext" +import { usePaymentsModel } from "#hooks/usePaymentsModel" import type { CodeType } from "#reducers/OrderReducer" import type { ChildrenFunction } from "#typings" import { manageGiftCard } from "#utils/adyen/manageGiftCard" @@ -20,12 +21,21 @@ interface Props extends Omit { export function GiftCardOrCouponCode({ children, type, ...props }: Props): JSX.Element | null { const { order, managePaymentProviderGiftCards } = useContext(OrderContext) - let codeType = type ? (`${type}_code` as const) : undefined - if (!type && order && "coupon_code" in order && order.coupon_code !== "") codeType = "coupon_code" + const paymentsModel = usePaymentsModel() + // On the `payment_sessions` model a gift card is a Payment Session, not an + // order code, so there is only ever a coupon to display here — including + // when the consumer explicitly asked for the gift card. + const isPaymentSessionsModel = paymentsModel === "payment_sessions" + let codeType = type && !isPaymentSessionsModel ? (`${type}_code` as const) : undefined + if (isPaymentSessionsModel) codeType = "coupon_code" + else if (!type && order && "coupon_code" in order && order.coupon_code !== "") + codeType = "coupon_code" else if (!type) codeType = "gift_card_code" const code = order && codeType ? order[codeType] : "" const hide = !(order && code) - if (managePaymentProviderGiftCards && type === "gift_card") { + // The provider-managed gift card lives inside an Adyen payment source, which + // only the `payment_source` model has. + if (managePaymentProviderGiftCards && type === "gift_card" && !isPaymentSessionsModel) { const giftCardData = manageGiftCard({ order }) if (!giftCardData) return null const displayCode = `${giftCardData.cardBrand} ${giftCardData.cardSummary}` diff --git a/packages/react-components/src/components/gift_cards/GiftCardOrCouponForm.tsx b/packages/react-components/src/components/gift_cards/GiftCardOrCouponForm.tsx index a1f98030..7ad08985 100644 --- a/packages/react-components/src/components/gift_cards/GiftCardOrCouponForm.tsx +++ b/packages/react-components/src/components/gift_cards/GiftCardOrCouponForm.tsx @@ -3,6 +3,7 @@ import { useRapidForm } from "rapid-form" import { type JSX, useCallback, useContext, useEffect, useState } from "react" import CouponAndGiftCardFormContext from "#context/CouponAndGiftCardFormContext" import OrderContext from "#context/OrderContext" +import { usePaymentsModel } from "#hooks/usePaymentsModel" import type { OrderCodeType } from "#reducers/OrderReducer" import type { DefaultChildrenType } from "#typings/globals" @@ -19,6 +20,7 @@ interface Props extends Omit { export function GiftCardOrCouponForm(props: Props): JSX.Element | null { const { children, codeType, autoComplete = "on", onSubmit, ...p } = props const { refValidation, values } = useRapidForm() + const paymentsModel = usePaymentsModel() const { setGiftCardOrCouponCode, order, errors, setOrderErrors } = useContext(OrderContext) const [type, setType] = useState(codeType) @@ -39,6 +41,16 @@ export function GiftCardOrCouponForm(props: Props): JSX.Element | null { // Derive the active code type from the current order state useEffect(() => { + // On the `payment_sessions` model a gift card is not an order-level code: + // it is spent by creating a Payment Session against a gift-card Payment + // Setting, so it appears among the payment methods instead. Writing + // `gift_card_code` on the order there is meaningless, and letting it + // through would silently apply a gift card that no session reflects — so + // this overrides an explicit `codeType` too, rather than trusting it. + if (paymentsModel === "payment_sessions") { + setType("coupon_code") + return + } if (codeType != null) { setType(codeType) return @@ -50,7 +62,7 @@ export function GiftCardOrCouponForm(props: Props): JSX.Element | null { } else if (!order?.gift_card_code && !order?.coupon_code) { setType("gift_card_or_coupon_code") } - }, [order, codeType]) + }, [order, codeType, paymentsModel]) const handleSubmit = useCallback( async (e: React.SyntheticEvent): Promise => { @@ -76,7 +88,16 @@ export function GiftCardOrCouponForm(props: Props): JSX.Element | null { [type, setGiftCardOrCouponCode, onSubmit] ) - if (codeType != null && order?.[codeType] != null && order?.[codeType] !== "") { + // Skip the "this slot is already filled" check when the model overrode the + // requested type: the consumer's `codeType` is no longer what is rendered, + // so testing the order against it would hide the coupon form for an order + // that merely has a leftover gift card code from the older model. + if ( + paymentsModel !== "payment_sessions" && + codeType != null && + order?.[codeType] != null && + order?.[codeType] !== "" + ) { return null } return (order?.gift_card_code && order?.coupon_code) || order == null ? null : ( diff --git a/packages/react-components/src/components/gift_cards/GiftCardOrCouponRemoveButton.tsx b/packages/react-components/src/components/gift_cards/GiftCardOrCouponRemoveButton.tsx index 8157ce86..d4e5daab 100644 --- a/packages/react-components/src/components/gift_cards/GiftCardOrCouponRemoveButton.tsx +++ b/packages/react-components/src/components/gift_cards/GiftCardOrCouponRemoveButton.tsx @@ -2,6 +2,7 @@ import type { Order } from "@commercelayer/sdk" import { type JSX, type ReactNode, useContext } from "react" import Parent from "#components/utils/Parent" import OrderContext from "#context/OrderContext" +import { usePaymentsModel } from "#hooks/usePaymentsModel" import type { CodeType, OrderCodeType } from "#reducers/OrderReducer" import type { ChildrenFunction } from "#typings/index" import { manageGiftCard } from "#utils/adyen/manageGiftCard" @@ -27,8 +28,14 @@ export function GiftCardOrCouponRemoveButton(props: Props): JSX.Element | null { managePaymentProviderGiftCards, paymentSourceRequest, } = useContext(OrderContext) - let codeType = type ? (`${type}_code` as const) : undefined - if (!type && order && "coupon_code" in order && order.coupon_code !== "") codeType = "coupon_code" + const paymentsModel = usePaymentsModel() + // Mirrors ``: on the `payment_sessions` model the gift + // card is a Payment Session, so only a coupon can be removed here. + const isPaymentSessionsModel = paymentsModel === "payment_sessions" + let codeType = type && !isPaymentSessionsModel ? (`${type}_code` as const) : undefined + if (isPaymentSessionsModel) codeType = "coupon_code" + else if (!type && order && "coupon_code" in order && order.coupon_code !== "") + codeType = "coupon_code" else if (!type) codeType = "gift_card_code" const code = order && codeType ? order[codeType] : "" let hide = !(order && code) diff --git a/packages/react-components/src/components/orders/PlaceOrderButton.tsx b/packages/react-components/src/components/orders/PlaceOrderButton.tsx index fdd4d08a..35ec2b49 100644 --- a/packages/react-components/src/components/orders/PlaceOrderButton.tsx +++ b/packages/react-components/src/components/orders/PlaceOrderButton.tsx @@ -1,5 +1,111 @@ -// Temporary alias. The next commit turns this file into the Payments Model -// router that picks between the `payment_source` and `payment_sessions` -// branches. Kept here so the move above stays a pure rename in the history. -export { PlaceOrderButtonPaymentSource as PlaceOrderButton } from "./PlaceOrderButtonPaymentSource" -export { default } from "./PlaceOrderButtonPaymentSource" +import type { Order } from "@commercelayer/sdk" +import type { JSX, ReactNode } from "react" +import Parent from "#components/utils/Parent" +import { usePaymentsModel } from "#hooks/usePaymentsModel" +import type { PlaceOrderOptions } from "#reducers/PlaceOrderReducer" +import type { BaseError } from "#typings/errors" +import type { ChildrenFunction } from "#typings/index" +import { PlaceOrderButtonPaymentSessions } from "./PlaceOrderButtonPaymentSessions" +import { PlaceOrderButtonPaymentSource } from "./PlaceOrderButtonPaymentSource" + +interface ChildrenProps extends Omit { + handleClick: () => Promise +} + +interface Props extends Omit { + children?: ChildrenFunction + label?: string | ReactNode | (() => ReactNode) + loadingLabel?: string | ReactNode + /** `payment_source` model only — the new model has no redirect flows yet. */ + autoPlaceOrder?: boolean + onClick?: (response: { placed: boolean; order?: Order; errors?: BaseError[] }) => void + /** + * Redirect-flow options (PayPal, Adyen, Stripe, Checkout.com). + * Meaningful on the `payment_source` model only, and forwarded only there. + */ + options?: PlaceOrderOptions + /** `payment_sessions` model only. Placeability attempts. Defaults to 5. */ + placeableAttempts?: number + /** `payment_sessions` model only. Delay between attempts in ms. Defaults to 1000. */ + placeableIntervalMs?: number +} + +/** + * Places the order, choosing the implementation that matches the order's + * Payments Model. + * + * This component exists so that the split is invisible to consumers: an + * application already mounting `` keeps working unchanged, + * whichever model its orders are on. The two implementations behind it share + * almost nothing — see + * `docs/adr/2026-08-18-place-order-split-by-payments-model.md`. + * + * Mount `` or + * `` directly to skip the routing when an + * application only ever sees one model. + */ +export function PlaceOrderButton(props: Props): JSX.Element { + const paymentsModel = usePaymentsModel() + const { + children, + label = "Place order", + loadingLabel = "Placing...", + autoPlaceOrder, + options, + placeableAttempts, + placeableIntervalMs, + disabled, + onClick, + ...p + } = props + + switch (paymentsModel) { + case "payment_source": + return ( + + {children} + + ) + case "payment_sessions": + return ( + + {children} + + ) + default: { + // The model is not knowable until the order has loaded. Render an inert + // button rather than delegating or hiding: mounting the `payment_source` + // branch would start five redirect effects that read a payment source + // this order may not have, and rendering `null` would make the button + // appear late — a visible change for applications upgrading, even though + // the mount hierarchy is identical. + const labelButton = typeof label === "function" ? label() : label + const handleClick = async (): Promise => undefined + return children ? ( + {children} + ) : ( + + ) + } + } +} + +export default PlaceOrderButton diff --git a/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx new file mode 100644 index 00000000..9117e511 --- /dev/null +++ b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx @@ -0,0 +1,169 @@ +import { + DEFAULT_PLACEABLE_ATTEMPTS, + DEFAULT_PLACEABLE_INTERVAL_MS, + findCurrentPaymentSession, + placeOrderWithPaymentSessions, +} from "@commercelayer/core-components" +import type { Order } from "@commercelayer/sdk" +import { type JSX, type MouseEvent, type ReactNode, useContext, useEffect, useState } from "react" +import Parent from "#components/utils/Parent" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext from "#context/OrderContext" +import { PLACE_ORDER_RECHECK_EVENT } from "#hooks/usePlaceOrder" +import type { BaseError } from "#typings/errors" +import type { ChildrenFunction } from "#typings/index" +import { useOrganizationConfig } from "#utils/organization" + +interface ChildrenProps extends Omit { + handleClick: () => Promise + isLoading: boolean +} + +interface Props extends Omit { + children?: ChildrenFunction + label?: string | ReactNode | (() => ReactNode) + loadingLabel?: string | ReactNode + onClick?: (response: { placed: boolean; order?: Order; errors?: BaseError[] }) => void + /** + * Placeability attempts before the errors are shown to the shopper. + * Defaults to 5. See `placeOrderWithPaymentSessions` for why retrying first + * is the correct behaviour rather than an optimisation. + */ + placeableAttempts?: number + /** Delay between placeability attempts, in milliseconds. Defaults to 1000. */ + placeableIntervalMs?: number +} + +/** + * Place-order button for the `payment_sessions` model. + * + * Deliberately **not** a branch inside `PlaceOrderButtonPaymentSource`: that + * component's enablement machine is built on `payment_method`, + * `payment_source.payment_response`, `getCardDetails` and gateway `onsubmit` + * refs, none of which exist here — and its upstream permission check hard-fails + * every non-free order without a `payment_method`. + * + * The button stays **enabled**. Placeability cannot be read before clicking: + * `order.placeable` is transient and never served on a GET, and it does not + * turn true until the asynchronous authorization has succeeded — so using it as + * a gate would disable the button precisely while payment is in progress. The + * truth arrives after the click, from `_placeable`. + */ +export function PlaceOrderButtonPaymentSessions(props: Props): JSX.Element { + const { + children, + label = "Place order", + loadingLabel = "Placing...", + disabled, + onClick, + placeableAttempts = DEFAULT_PLACEABLE_ATTEMPTS, + placeableIntervalMs = DEFAULT_PLACEABLE_INTERVAL_MS, + ...p + } = props + const { order, setOrderErrors, getOrder } = useContext(OrderContext) + const { accessToken, interceptors } = useContext(CommerceLayerContext) + const [isLoading, setIsLoading] = useState(false) + const organizationConfig = useOrganizationConfig({ accessToken }) + const [privacyTermsChecked, setPrivacyTermsChecked] = useState( + () => localStorage.getItem("privacy-terms") === "true" + ) + + // The privacy and terms gate is a legal requirement of the checkout, not a + // property of the payment model, so it applies here exactly as it does to the + // older branch — same storage key, same "only when both URLs are configured" + // rule as `placeOrderPermitted`. + // + // `` announces changes through a DOM event rather + // than context, so there is nothing to read: we listen for the same event the + // older branch listens for. + useEffect(() => { + const recheck = (): void => { + setPrivacyTermsChecked(localStorage.getItem("privacy-terms") === "true") + } + window.addEventListener(PLACE_ORDER_RECHECK_EVENT, recheck) + return () => { + window.removeEventListener(PLACE_ORDER_RECHECK_EVENT, recheck) + } + }, []) + + const privacyUrl = order?.privacy_url ?? organizationConfig?.urls?.privacy + const termsUrl = order?.terms_url ?? organizationConfig?.urls?.terms + const privacyAccepted = privacyUrl && termsUrl ? privacyTermsChecked : true + + const handleClick = async (event?: MouseEvent): Promise => { + event?.preventDefault() + event?.stopPropagation() + if (order == null || accessToken == null || isLoading) return + + setIsLoading(true) + setOrderErrors([]) + try { + // The Current Payment Session is searched for, never read positionally: + // abandoned attempts leave inert sessions behind, and switching setting + // leaves the previous one in the same array. + const paymentSession = findCurrentPaymentSession({ + paymentSessions: order.payment_sessions, + }) + + const result = await placeOrderWithPaymentSessions({ + accessToken, + interceptors, + orderId: order.id, + paymentSession, + attempts: placeableAttempts, + intervalMs: placeableIntervalMs, + }) + + if (result.placed) { + onClick?.({ placed: true, order: result.order }) + return + } + + const errors: BaseError[] = result.errors.map((error) => ({ + code: "VALIDATION_ERROR", + resource: "orders", + message: error.message, + field: error.field, + ...(error.meta != null ? { meta: error.meta } : {}), + })) + setOrderErrors(errors) + onClick?.({ placed: false, order: result.order, errors }) + // The order moved on without us — an authorization may have landed, or + // auto_place may have fired — so pull the truth back in rather than + // leaving the shopper looking at stale amounts. + await getOrder(order.id) + } catch (error) { + const errors: BaseError[] = [ + { + code: "VALIDATION_ERROR", + resource: "orders", + message: error instanceof Error ? error.message : "The order could not be placed.", + }, + ] + setOrderErrors(errors) + onClick?.({ placed: false, errors }) + } finally { + setIsLoading(false) + } + } + + const disabledButton = disabled !== undefined ? disabled : !privacyAccepted + const labelButton = isLoading ? loadingLabel : typeof label === "function" ? label() : label + + return children ? ( + {children} + ) : ( + + ) +} + +export default PlaceOrderButtonPaymentSessions diff --git a/packages/react-components/src/components/payment_settings/PaymentSetting.tsx b/packages/react-components/src/components/payment_settings/PaymentSetting.tsx new file mode 100644 index 00000000..dbebda46 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSetting.tsx @@ -0,0 +1,152 @@ +import { + createPaymentSession, + findCurrentPaymentSession, + findReusablePaymentSession, +} from "@commercelayer/core-components" +import type { PaymentSetting as PaymentSettingResource } from "@commercelayer/sdk" +import { type JSX, type ReactNode, useContext, useEffect, useState } from "react" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext from "#context/OrderContext" +import PaymentSettingChildrenContext from "#context/PaymentSettingChildrenContext" +import { usePaymentsModel } from "#hooks/usePaymentsModel" +import type { BaseError } from "#typings/errors" + +/** + * Payment Setting types this library can drive today. + * + * Anything not listed is skipped entirely rather than rendered inert: a radio + * button for a setting with no implementation behind it does nothing when + * clicked, which is worse for the shopper than not offering it. + * + * The goal is to cover all six. See the implementation table in + * `docs/adr/2026-08-18-payment-session-lifecycle.md`. + */ +const IMPLEMENTED_SETTING_TYPES = ["payment_setting_manuals"] as const + +interface Props { + children?: ReactNode +} + +/** + * Iterate the order's `available_payment_settings` and render `children` once + * per setting, with that setting in context. + * + * Renders nothing unless the order is on the `payment_sessions` model, so it + * can be mounted alongside `` without a coordinator above: each + * tree silently steps aside when the order is not its own. + */ +export function PaymentSetting({ children }: Props): JSX.Element | null { + const paymentsModel = usePaymentsModel() + const { order, include, includeLoaded, addResourceToInclude, getOrder } = useContext(OrderContext) + const { accessToken, interceptors } = useContext(CommerceLayerContext) + const [pendingSettingId, setPendingSettingId] = useState(null) + const [errors, setErrors] = useState([]) + + // Reading a selection back needs the session's setting; telling a reusable + // session from a burnt one needs its authorization. Registered here rather + // than globally: two levels of nesting on a collection is the expensive part + // of the payload, and only this subtree needs it. + useEffect(() => { + const needed = [ + "payment_sessions.payment_setting", + "payment_sessions.payment_authorization", + ] as const + if (!needed.every((resource) => include?.includes(resource))) { + addResourceToInclude({ newResource: [...needed] }) + } else if (needed.some((resource) => includeLoaded?.[resource] !== true)) { + addResourceToInclude({ + newResourceLoaded: { + "payment_sessions.payment_setting": true, + "payment_sessions.payment_authorization": true, + }, + }) + } + }, [include, includeLoaded, addResourceToInclude]) + + if (paymentsModel !== "payment_sessions" || order == null) return null + + const settings = (order.available_payment_settings ?? []).filter((setting) => { + const implemented = IMPLEMENTED_SETTING_TYPES.includes( + setting.type as (typeof IMPLEMENTED_SETTING_TYPES)[number] + ) + if (!implemented && process.env.NODE_ENV !== "production") { + // Without this, an organization whose only configured settings are + // unimplemented gets a checkout with no payment options and no clue why. + console.warn( + `[commercelayer] skipped "${setting.type}": not implemented yet.` + ) + } + return implemented + }) + + const selectSetting = async (setting: PaymentSettingResource): Promise => { + if (accessToken == null || order == null) return + setErrors([]) + setPendingSettingId(setting.id) + try { + // Reuse before creating. `amount_cents` is immutable, so there is no + // "update the existing session" path — without this, every remount or + // refetch that re-runs the click handler would leave another session + // behind. Reuse is also what makes a page refresh resume the selection + // instead of duplicating it. + const reusable = findReusablePaymentSession({ + paymentSessions: order.payment_sessions, + paymentSettingId: setting.id, + }) + if (reusable == null) { + await createPaymentSession({ + accessToken, + interceptors, + orderId: order.id, + paymentSettingId: setting.id, + }) + } + await getOrder(order.id) + } catch (error) { + setErrors([ + { + code: "VALIDATION_ERROR", + resource: "payment_methods", + message: + error instanceof Error ? error.message : "The payment method could not be selected.", + }, + ]) + } finally { + setPendingSettingId(null) + } + } + + // One selection for the whole order, not one per setting. Switching setting + // leaves the previous session behind — it is inert and may not be deletable + // with a sales-channel token — so without this every setting the shopper has + // tried would render as selected at the same time. + const selectedSession = findCurrentPaymentSession({ paymentSessions: order.payment_sessions }) + + return ( + <> + {settings.map((setting) => { + const isSelected = selectedSession?.payment_setting?.id === setting.id + const currentPaymentSession = isSelected ? selectedSession : undefined + return ( + { + await selectSetting(setting) + }, + }} + > + {children} + + ) + })} + + ) +} + +export default PaymentSetting diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingManualPayment.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingManualPayment.tsx new file mode 100644 index 00000000..98254bc3 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingManualPayment.tsx @@ -0,0 +1,68 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import type { JSX, ReactNode } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingChildrenContext from "#context/PaymentSettingChildrenContext" +import type { BaseError } from "#typings/errors" +import type { ChildrenFunction } from "#typings/index" +import useCustomContext from "#utils/hooks/useCustomContext" + +interface ChildrenProps extends Omit { + /** Whether this setting is the shopper's current choice. */ + isSelected: boolean + /** Whether the Payment Session is being created right now. */ + isPending: boolean + /** + * The Payment Session backing the selection, once it exists. Carries + * `formatted_amount` and `expires_at` for display. + */ + currentPaymentSession?: PaymentSession + errors: BaseError[] +} + +interface Props { + children?: ChildrenFunction + /** + * Rendered when this setting is selected — payment instructions, for + * example the bank details for a wire transfer. + */ + instructions?: ReactNode +} + +/** + * The manual Payment Setting (`payment_setting_manuals`): paying out of band, + * for instance by bank transfer. + * + * There is no gateway UI and nothing to collect, so selecting the setting is + * the whole interaction — the Payment Session created by `` is + * all the API needs. Taking the money happens later, at place time, when the + * Payment Authorization is created: that keeps selection reversible, so a + * shopper changing their mind costs nothing. + * + * Renders `null` unless its setting is selected, so it can be dropped inside + * `` alongside other settings' components. + */ +export function PaymentSettingManualPayment(props: Props): JSX.Element | null { + const { children, instructions } = props + const { setting, currentPaymentSession, isSelected, isPending, errors } = useCustomContext({ + context: PaymentSettingChildrenContext, + contextComponentName: "PaymentSetting", + currentComponentName: "PaymentSettingManualPayment", + key: "setting", + }) + + if (setting?.type !== "payment_setting_manuals") return null + + const parentProps = { + ...props, + isSelected: isSelected === true, + isPending: isPending === true, + currentPaymentSession, + errors: errors ?? [], + } + + if (children) return {children} + if (isSelected !== true) return null + return <>{instructions} +} + +export default PaymentSettingManualPayment diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingName.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingName.tsx new file mode 100644 index 00000000..15093d27 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingName.tsx @@ -0,0 +1,47 @@ +import type { JSX } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingChildrenContext from "#context/PaymentSettingChildrenContext" +import type { ChildrenFunction } from "#typings/index" +import useCustomContext from "#utils/hooks/useCustomContext" + +interface ChildrenProps extends Omit { + labelName: string +} + +interface Props extends Omit { + children?: ChildrenFunction +} + +/** Fallback labels for settings an organization has not named. */ +const DEFAULT_LABELS: Record = { + payment_setting_adyens: "Adyen", + payment_setting_braintrees: "Braintree", + payment_setting_externals: "External", + payment_setting_gift_cards: "Gift card", + payment_setting_manuals: "Manual payment", + payment_setting_stripes: "Stripe", +} + +export function PaymentSettingName(props: Props): JSX.Element { + const { setting } = useCustomContext({ + context: PaymentSettingChildrenContext, + contextComponentName: "PaymentSetting", + currentComponentName: "PaymentSettingName", + key: "setting", + }) + // `name` is optional on every payment setting type, so fall back to the + // resource type rather than rendering an unlabelled radio. + const labelName = setting?.name ?? DEFAULT_LABELS[setting?.type ?? ""] ?? setting?.type ?? "" + const htmlFor = setting?.id + const parentProps = { htmlFor, labelName, ...props } + + return props.children ? ( + {props.children} + ) : ( + + ) +} + +export default PaymentSettingName diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingRadioButton.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingRadioButton.tsx new file mode 100644 index 00000000..8ebc5842 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingRadioButton.tsx @@ -0,0 +1,62 @@ +import type { ChangeEvent, JSX } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingChildrenContext from "#context/PaymentSettingChildrenContext" +import type { ChildrenFunction } from "#typings/index" +import useCustomContext from "#utils/hooks/useCustomContext" + +interface ChildrenProps extends Omit { + checked: boolean + handleOnChange: (event: ChangeEvent) => Promise +} + +type Props = { + children?: ChildrenFunction +} & JSX.IntrinsicElements["input"] + +/** + * Radio button selecting the Payment Setting of the surrounding + * ``. + * + * `checked` is derived from the order, never from local state: the selection + * *is* the Payment Session, so what the shopper sees always reflects what the + * API stored. The cost is that the radio does not light up on click — it lights + * up once the session exists. `disabled` covers that round trip. + */ +export function PaymentSettingRadioButton(props: Props): JSX.Element { + const { children, ...p } = props + const { setting, isSelected, isPending, selectSetting } = useCustomContext({ + context: PaymentSettingChildrenContext, + contextComponentName: "PaymentSetting", + currentComponentName: "PaymentSettingRadioButton", + key: "setting", + }) + + const checked = isSelected === true + const id = setting?.id + + const handleOnChange = async (event: ChangeEvent): Promise => { + event.stopPropagation() + if (checked || isPending === true) return + await selectSetting?.() + } + + const parentProps = { handleOnChange, checked, id, disabled: isPending, ...props } + + return children ? ( + {children} + ) : ( + { + void handleOnChange(event) + }} + {...p} + /> + ) +} + +export default PaymentSettingRadioButton diff --git a/packages/react-components/src/context/PaymentSettingChildrenContext.ts b/packages/react-components/src/context/PaymentSettingChildrenContext.ts new file mode 100644 index 00000000..30145bd5 --- /dev/null +++ b/packages/react-components/src/context/PaymentSettingChildrenContext.ts @@ -0,0 +1,31 @@ +import type { PaymentSession, PaymentSetting } from "@commercelayer/sdk" +import { createContext } from "react" +import type { BaseError } from "#typings/errors" + +export interface InitialPaymentSettingChildrenContext { + /** The Payment Setting this subtree renders, from `available_payment_settings`. */ + setting?: PaymentSetting + /** + * The Payment Session pointing at this setting, if any. This *is* the + * selection — the order carries no `payment_setting` relationship — so it + * survives a reload and always wins over anything held in the browser. + */ + currentPaymentSession?: PaymentSession + /** Whether this setting is the shopper's current choice. */ + isSelected?: boolean + /** + * Whether a session is being created for this setting right now. This is not + * the selection: the selection is derived from the order and only becomes + * true once the API has answered. + */ + isPending?: boolean + errors?: BaseError[] + /** Select this setting, creating or adopting its Payment Session. */ + selectSetting?: () => Promise +} + +const initial: InitialPaymentSettingChildrenContext = {} + +const PaymentSettingChildrenContext = createContext(initial) + +export default PaymentSettingChildrenContext diff --git a/packages/react-components/src/hooks/useOrderState.ts b/packages/react-components/src/hooks/useOrderState.ts index 5b962811..850af12d 100644 --- a/packages/react-components/src/hooks/useOrderState.ts +++ b/packages/react-components/src/hooks/useOrderState.ts @@ -126,6 +126,31 @@ export function useOrderState({ } }, [attributes, state?.order, lock]) + // Ask for `available_payment_settings` on every order fetch, for every + // consumer. This is what makes the Payments Model derivable: an order that + // was never asked for the relationship looks exactly like an order on the + // older model, and `usePaymentsModel` would silently pick the wrong branch. + // + // The cost — one relationship on carts that will never show a payment method + // — is accepted deliberately, because the alternative is making every + // consumer opt in to a correctness requirement they cannot see. + useEffect(() => { + if (state.withoutIncludes === true) return + const register = (args: AddResourceToInclude): void => { + defaultOrderContext.addResourceToInclude({ + ...args, + dispatch, + resourcesIncluded: state.include, + resourceIncludedLoaded: state.includeLoaded, + }) + } + if (!state.include?.includes("available_payment_settings")) { + register({ newResource: ["available_payment_settings"] }) + } else if (state.includeLoaded?.available_payment_settings !== true) { + register({ newResourceLoaded: { available_payment_settings: true } }) + } + }, [state.include, state.includeLoaded, state.withoutIncludes]) + // biome-ignore lint/correctness/useExhaustiveDependencies: complex dep array mirrors original OrderContainer — adding all deps causes fetch loops useEffect(() => { const localOrder = persistKey ? getLocalOrder(persistKey) : orderId diff --git a/packages/react-components/src/hooks/usePaymentsModel.ts b/packages/react-components/src/hooks/usePaymentsModel.ts new file mode 100644 index 00000000..3a08988b --- /dev/null +++ b/packages/react-components/src/hooks/usePaymentsModel.ts @@ -0,0 +1,54 @@ +import { useContext, useMemo } from "react" +import OrderContext from "#context/OrderContext" + +/** + * Which of the two mutually exclusive payment models an order uses. + * + * An order is bound to one model for its whole life and can never switch. The + * values are named after the order relationship that carries the payment, not + * after their age — "legacy" and "new" age badly, and "v1/v2" collides with the + * API version, which is a different thing entirely. + */ +export type PaymentsModel = + /** `payment_gateways` + `payment_methods` + one payment source per order. */ + | "payment_source" + /** `payment_settings` + `payment_sessions`. */ + | "payment_sessions" + /** The order data needed to decide has not loaded yet. */ + | "undetermined" + +/** + * Derive the Payments Model from the order. + * + * API version `2026-05` is purely additive, so a single response can carry + * **both** `available_payment_methods` and `available_payment_settings`. When + * it does, the newer model wins and the older flow is excluded entirely. + * + * That precedence lives here, in the library, and never in the consuming + * application. Pushing it out — for instance by having the app blank out + * `available_payment_methods` after fetching — turns a domain rule into + * something every consumer has to remember, and mutates an API response to make + * components behave. + * + * The derivation is pure: it never fetches and holds no state of its own, so it + * cannot drift from the order it describes. It relies on `` having + * registered `available_payment_settings` in the order include, which it does + * for every consumer — without that, an absent array would be indistinguishable + * from one that was never requested. + * + * @returns the model, and `"undetermined"` until the order is loaded. That + * third state is real and observable, not a transient detail: every caller has + * to render something sensible for it. + */ +export function usePaymentsModel(): PaymentsModel { + const { order } = useContext(OrderContext) + + return useMemo(() => { + if (order == null) return "undetermined" + if ((order.available_payment_settings ?? []).length > 0) return "payment_sessions" + if ((order.available_payment_methods ?? []).length > 0) return "payment_source" + return "undetermined" + }, [order]) +} + +export default usePaymentsModel diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts index 4cba1f7f..c0143403 100644 --- a/packages/react-components/src/index.ts +++ b/packages/react-components/src/index.ts @@ -66,6 +66,7 @@ export * from "#components/orders/OrderNumber" export * from "#components/orders/OrderStorage" export * from "#components/orders/PaymentMethodAmount" export * from "#components/orders/PlaceOrderButton" +export * from "#components/orders/PlaceOrderButtonPaymentSessions" export * from "#components/orders/PlaceOrderButtonPaymentSource" export * from "#components/orders/PlaceOrderContainer" export * from "#components/orders/PrivacyAndTermsCheckbox" @@ -84,6 +85,10 @@ export * from "#components/payment_methods/PaymentMethodName" export * from "#components/payment_methods/PaymentMethodPrice" export * from "#components/payment_methods/PaymentMethodRadioButton" export * from "#components/payment_methods/PaymentMethodsContainer" +export * from "#components/payment_settings/PaymentSetting" +export * from "#components/payment_settings/PaymentSettingManualPayment" +export * from "#components/payment_settings/PaymentSettingName" +export * from "#components/payment_settings/PaymentSettingRadioButton" export * from "#components/payment_source/PaymentSource" export * from "#components/payment_source/PaymentSourceBrandIcon" export * from "#components/payment_source/PaymentSourceBrandName" @@ -117,5 +122,6 @@ export * from "#components/stock_transfers/StockTransferField" export * from "#hooks/useCommerceLayer" export * from "#hooks/useCustomerContainer" export * from "#hooks/useOrderContainer" +export * from "#hooks/usePaymentsModel" export * from "#hooks/useTermsAndConditions" export * from "#typings/errors" diff --git a/packages/react-components/src/reducers/OrderReducer.ts b/packages/react-components/src/reducers/OrderReducer.ts index 2f48004c..8d2adec6 100644 --- a/packages/react-components/src/reducers/OrderReducer.ts +++ b/packages/react-components/src/reducers/OrderReducer.ts @@ -84,6 +84,14 @@ export type ResourceIncluded = | "payment_source" | "available_payment_methods" | "payment_method" + // `payment_sessions` model. `available_payment_settings` is registered by + // `` for every consumer: without it, an absent array cannot be told + // apart from one that was never asked for, and the Payments Model cannot be + // derived. The two nested ones are registered by `` only — + // they are the expensive part and nothing outside the payment UI needs them. + | "available_payment_settings" + | "payment_sessions.payment_setting" + | "payment_sessions.payment_authorization" type ResourceIncludedLoaded = Partial> From 0b95b03ad46ed8f2b51f0a990d59358f2711ecef Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:10:11 +0200 Subject: [PATCH 05/21] feat: silence the payment_source tree on the newer model, add onSelect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps found while mounting these components in mfe-checkout. kept rendering. Only stepped aside for the other model, but 2026-05 is additive: an order on the payment_sessions model still carries available_payment_methods, so both trees rendered and the shopper saw two sets of payment options, one of them dead. This is where the precedence rule actually takes effect — without it, mounting both trees together needs a conditional in every consuming app. had no way to report a selection. has onClick; the counterpart was missing, so an app could not refresh its own order state when the choice changed. onSelect fires after the selection is stored and the order refetched, not on click. Co-Authored-By: Claude Opus 5 (1M context) --- .../payment_settings/PaymentSetting.spec.tsx | 33 ++++++++++++++++++ .../payment_methods/PaymentMethod.tsx | 10 ++++++ .../payment_settings/PaymentSetting.tsx | 34 +++++++++++++++++-- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx b/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx index d4400601..ac8e6e78 100644 --- a/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx +++ b/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx @@ -2,6 +2,7 @@ import type { Order } from "@commercelayer/sdk" import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import type { ReactNode } from "react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { PaymentMethod } from "#components/payment_methods/PaymentMethod" import { PaymentSetting } from "#components/payment_settings/PaymentSetting" import { PaymentSettingManualPayment } from "#components/payment_settings/PaymentSettingManualPayment" import { PaymentSettingName } from "#components/payment_settings/PaymentSettingName" @@ -242,4 +243,36 @@ describe("PaymentSetting", () => { expect(createPaymentSessionMock).not.toHaveBeenCalled() }) }) + + // Both trees can be mounted together with no coordinator above them. 2026-05 + // is additive, so an order on the newer model still carries + // available_payment_methods — without this the shopper would see two sets of + // payment options, one of them dead. + describe("precedence over the payment_source tree", () => { + it("silences on the payment_sessions model", () => { + render( + + + old + + + + + + ) + expect(screen.queryByTestId("old-tree")).toBeNull() + expect(screen.getByTestId("name").textContent).toBe("Bank transfer") + }) + }) }) diff --git a/packages/react-components/src/components/payment_methods/PaymentMethod.tsx b/packages/react-components/src/components/payment_methods/PaymentMethod.tsx index 2bba11f7..5ad88c30 100644 --- a/packages/react-components/src/components/payment_methods/PaymentMethod.tsx +++ b/packages/react-components/src/components/payment_methods/PaymentMethod.tsx @@ -6,6 +6,7 @@ import PaymentMethodChildrenContext from "#context/PaymentMethodChildrenContext" import PaymentMethodContext from "#context/PaymentMethodContext" import PlaceOrderContext from "#context/PlaceOrderContext" import { usePaymentMethod } from "#hooks/usePaymentMethod" +import { usePaymentsModel } from "#hooks/usePaymentsModel" import type { PaymentMethodConfig, PaymentResource } from "#reducers/PaymentMethodReducer" import type { LoaderType } from "#typings" import type { DefaultChildrenType } from "#typings/globals" @@ -88,6 +89,7 @@ export function PaymentMethod({ config: configProp, ...p }: Props): JSX.Element { + const paymentsModel = usePaymentsModel() const [loading, setLoading] = useState(true) const [paymentSelected, setPaymentSelected] = useState("") const [paymentSourceCreated, setPaymentSourceCreated] = useState(false) @@ -392,6 +394,14 @@ export function PaymentMethod({ const content = !loading || hasRenderedMethodsRef.current ? <>{components} : getLoaderComponent(loader) + // Step aside on the `payment_sessions` model. API version 2026-05 is + // additive, so an order on the newer model still carries + // `available_payment_methods` and this component would happily render them + // alongside the newer tree — two sets of payment options, one of them + // meaningless. This is where the precedence rule actually takes effect, and + // it is what lets both trees be mounted together with no coordinator above. + if (paymentsModel === "payment_sessions") return <> + // In standalone mode provide the context so that child components // (PaymentSource, PaymentGateway, etc.) can read payment state without // a surrounding . diff --git a/packages/react-components/src/components/payment_settings/PaymentSetting.tsx b/packages/react-components/src/components/payment_settings/PaymentSetting.tsx index dbebda46..d82b5544 100644 --- a/packages/react-components/src/components/payment_settings/PaymentSetting.tsx +++ b/packages/react-components/src/components/payment_settings/PaymentSetting.tsx @@ -3,7 +3,11 @@ import { findCurrentPaymentSession, findReusablePaymentSession, } from "@commercelayer/core-components" -import type { PaymentSetting as PaymentSettingResource } from "@commercelayer/sdk" +import type { + Order, + PaymentSession, + PaymentSetting as PaymentSettingResource, +} from "@commercelayer/sdk" import { type JSX, type ReactNode, useContext, useEffect, useState } from "react" import CommerceLayerContext from "#context/CommerceLayerContext" import OrderContext from "#context/OrderContext" @@ -23,8 +27,24 @@ import type { BaseError } from "#typings/errors" */ const IMPLEMENTED_SETTING_TYPES = ["payment_setting_manuals"] as const +export interface PaymentSettingOnSelectParams { + setting: PaymentSettingResource + /** The order as refetched after the selection was stored. */ + order?: Order + /** The Payment Session backing the selection. */ + paymentSession?: PaymentSession +} + interface Props { children?: ReactNode + /** + * Fired once the selection has been stored and the order refetched — not on + * click. The counterpart of ``'s `onClick`. + * + * Keep the identity stable (`useCallback`): it is read during the selection + * handler, and an unstable one is the usual route to a render loop here. + */ + onSelect?: (params: PaymentSettingOnSelectParams) => void } /** @@ -35,7 +55,7 @@ interface Props { * can be mounted alongside `` without a coordinator above: each * tree silently steps aside when the order is not its own. */ -export function PaymentSetting({ children }: Props): JSX.Element | null { +export function PaymentSetting({ children, onSelect }: Props): JSX.Element | null { const paymentsModel = usePaymentsModel() const { order, include, includeLoaded, addResourceToInclude, getOrder } = useContext(OrderContext) const { accessToken, interceptors } = useContext(CommerceLayerContext) @@ -101,7 +121,15 @@ export function PaymentSetting({ children }: Props): JSX.Element | null { paymentSettingId: setting.id, }) } - await getOrder(order.id) + const refreshed = await getOrder(order.id) + onSelect?.({ + setting, + order: refreshed, + paymentSession: findCurrentPaymentSession({ + paymentSessions: refreshed?.payment_sessions ?? order.payment_sessions, + paymentSettingId: setting.id, + }), + }) } catch (error) { setErrors([ { From e60b9e7c73ed05e5cdbb694a16a4ac87766330fc Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:03:42 +0200 Subject: [PATCH 06/21] fix: register available_payment_settings on the initial order fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The include was never registered, so every order loaded without available_payment_settings and usePaymentsModel() reported payment_source for orders that carry both arrays — the payment_source tree rendered and the newer one stayed empty. The guard was reading `withoutIncludes` as a consumer opt-out. It is not: it starts `true` and means "nothing has asked for an include yet", and registering one is what flips it. Skipping on `true` meant skipping always. Also drop the second phase: addResourceToInclude marks the resource loaded in the same call, so the two-phase idiom the containers use is belt-and-braces and one call suffices. Covered by a spec asserting the retrieve params, for both this relationship and the nested session ones registers — they have to reach the *initial* fetch, since adding an include later does not refetch. Co-Authored-By: Claude Opus 5 (1M context) --- .../order-payment-settings-include.spec.tsx | 70 +++++++++++++++++++ .../src/hooks/useOrderState.ts | 29 ++++---- 2 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 packages/react-components/specs/orders/order-payment-settings-include.spec.tsx diff --git a/packages/react-components/specs/orders/order-payment-settings-include.spec.tsx b/packages/react-components/specs/orders/order-payment-settings-include.spec.tsx new file mode 100644 index 00000000..8620e391 --- /dev/null +++ b/packages/react-components/specs/orders/order-payment-settings-include.spec.tsx @@ -0,0 +1,70 @@ +import { render, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { Order } from "#components/orders/Order" +import { PaymentSetting } from "#components/payment_settings/PaymentSetting" +import CommerceLayerContext from "#context/CommerceLayerContext" + +const { retrieveMock } = vi.hoisted(() => ({ retrieveMock: vi.fn() })) + +vi.mock("@commercelayer/core-components", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getSdk: vi.fn().mockReturnValue({ + orders: { retrieve: retrieveMock }, + }), + } +}) + +beforeEach(() => { + vi.clearAllMocks() + retrieveMock.mockResolvedValue({ id: "order-1", type: "orders", status: "pending" }) +}) + +describe(" order fetch", () => { + // The whole Payments Model detection rests on this: an order that was never + // asked for `available_payment_settings` is indistinguishable from an order + // on the older model, and every consumer would silently take the wrong branch. + it("asks for available_payment_settings even with no payment components mounted", async () => { + render( + + + + + + ) + + await waitFor(() => { + expect(retrieveMock).toHaveBeenCalled() + }) + + const params = retrieveMock.mock.calls.at(-1)?.[1] + expect(params?.include).toContain("available_payment_settings") + }) + + // The other half: reading a selection back needs the session's setting, and + // telling a live session from a burnt one needs its authorization. These are + // registered by and must reach the *initial* fetch — + // registering an include after the order has loaded does not refetch, so a + // late arrival would mean the data never comes. + it("asks for the nested session relationships when is mounted", async () => { + render( + + + + + + + + ) + + await waitFor(() => { + expect(retrieveMock).toHaveBeenCalled() + }) + + const params = retrieveMock.mock.calls.at(-1)?.[1] + expect(params?.include).toContain("available_payment_settings") + expect(params?.include).toContain("payment_sessions.payment_setting") + expect(params?.include).toContain("payment_sessions.payment_authorization") + }) +}) diff --git a/packages/react-components/src/hooks/useOrderState.ts b/packages/react-components/src/hooks/useOrderState.ts index 850af12d..d953d052 100644 --- a/packages/react-components/src/hooks/useOrderState.ts +++ b/packages/react-components/src/hooks/useOrderState.ts @@ -134,22 +134,21 @@ export function useOrderState({ // The cost — one relationship on carts that will never show a payment method // — is accepted deliberately, because the alternative is making every // consumer opt in to a correctness requirement they cannot see. + // + // Note `withoutIncludes` is *not* a consumer opt-out to respect here: it + // starts `true` and means "nothing has asked for an include yet". + // Registering one is what flips it, which `addResourceToInclude` does — along + // with marking the resource loaded, so the two-phase idiom the containers use + // is belt-and-braces and one call is enough. useEffect(() => { - if (state.withoutIncludes === true) return - const register = (args: AddResourceToInclude): void => { - defaultOrderContext.addResourceToInclude({ - ...args, - dispatch, - resourcesIncluded: state.include, - resourceIncludedLoaded: state.includeLoaded, - }) - } - if (!state.include?.includes("available_payment_settings")) { - register({ newResource: ["available_payment_settings"] }) - } else if (state.includeLoaded?.available_payment_settings !== true) { - register({ newResourceLoaded: { available_payment_settings: true } }) - } - }, [state.include, state.includeLoaded, state.withoutIncludes]) + if (state.include?.includes("available_payment_settings")) return + defaultOrderContext.addResourceToInclude({ + newResource: ["available_payment_settings"], + dispatch, + resourcesIncluded: state.include, + resourceIncludedLoaded: state.includeLoaded, + }) + }, [state.include, state.includeLoaded]) // biome-ignore lint/correctness/useExhaustiveDependencies: complex dep array mirrors original OrderContainer — adding all deps causes fetch loops useEffect(() => { From 1cd2a0de3f06ceddc78eea8dbc07887aeaa1f80a Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:24:00 +0200 Subject: [PATCH 07/21] fix: stop PaymentMethod writing to orders on the payment_sessions model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning null from render does not stop a mounted component's effects. PaymentMethod stepped aside visually but its auto-select effect still ran, wrote a payment_method and created a payment source — and the API drops available_payment_settings for an order with either set (order_payments.rb:169 via old_payments_engaged?). The order then read as payment_source, so the older tree took over: settings appeared for an instant, then vanished for good. Guard the two order-writing effects on the model as well as the render. Observed end to end: a fresh order lost its five payment settings on first page load and kept them afterwards. Co-Authored-By: Claude Opus 5 (1M context) --- .../PaymentMethod.paymentsModel.spec.tsx | 87 +++++++++++++++++++ .../payment_methods/PaymentMethod.tsx | 14 +++ 2 files changed, 101 insertions(+) create mode 100644 packages/react-components/specs/payment_methods/PaymentMethod.paymentsModel.spec.tsx diff --git a/packages/react-components/specs/payment_methods/PaymentMethod.paymentsModel.spec.tsx b/packages/react-components/specs/payment_methods/PaymentMethod.paymentsModel.spec.tsx new file mode 100644 index 00000000..002122a1 --- /dev/null +++ b/packages/react-components/specs/payment_methods/PaymentMethod.paymentsModel.spec.tsx @@ -0,0 +1,87 @@ +import type { Order } from "@commercelayer/sdk" +import { render, screen, waitFor } from "@testing-library/react" +import type { ReactNode } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { PaymentMethod } from "#components/payment_methods/PaymentMethod" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import PaymentMethodContext, { defaultPaymentMethodContext } from "#context/PaymentMethodContext" + +const setPaymentMethod = vi.fn().mockResolvedValue({ order: undefined }) +const setPaymentSource = vi.fn().mockResolvedValue(undefined) + +const METHOD = { id: "pm-1", payment_source_type: "wire_transfers", name: "Wire Transfer" } +const SETTING = { id: "ps-manual", type: "payment_setting_manuals", name: "Manual" } + +function Wrapper({ children, order }: { children: ReactNode; order: Partial }) { + return ( + + + + {children} + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe(" on the payment_sessions model", () => { + // Returning null from render does NOT stop a mounted component's effects. + // Without an explicit guard inside them, auto-select writes a payment_method + // onto the order; the API then drops available_payment_settings, and the + // order is flipped onto the older model permanently. + it("does not auto-select behind an inactive tree", async () => { + render( + + + old + + + ) + + await waitFor(() => { + expect(screen.queryByTestId("old-tree")).toBeNull() + }) + expect(setPaymentMethod).not.toHaveBeenCalled() + expect(setPaymentSource).not.toHaveBeenCalled() + }) + + it("still auto-selects on the payment_source model", async () => { + render( + + + old + + + ) + + await waitFor(() => { + expect(setPaymentMethod).toHaveBeenCalledWith( + expect.objectContaining({ paymentMethodId: "pm-1" }) + ) + }) + }) +}) diff --git a/packages/react-components/src/components/payment_methods/PaymentMethod.tsx b/packages/react-components/src/components/payment_methods/PaymentMethod.tsx index 5ad88c30..2e0feab7 100644 --- a/packages/react-components/src/components/payment_methods/PaymentMethod.tsx +++ b/packages/react-components/src/components/payment_methods/PaymentMethod.tsx @@ -125,6 +125,13 @@ export function PaymentMethod({ */ const isPartiallyAuthorized = order?.payment_status === "partially_authorized" useEffect(() => { + // Silencing this component in render is not enough: React runs a mounted + // component's effects whatever it returns, so without this the newer + // model's orders get a payment_method written behind the tree that is + // supposed to be inactive — and the API then drops + // available_payment_settings, flipping the order onto the older model for + // good. + if (paymentsModel === "payment_sessions") return if (paymentMethods != null && !isEmpty(paymentMethods) && expressPayments) { const [paymentMethod] = getAvailableExpressPayments(paymentMethods) if (!paymentSource && paymentMethod != null) { @@ -166,6 +173,13 @@ export function PaymentMethod({ showLoader, ]) useEffect(() => { + // Silencing this component in render is not enough: React runs a mounted + // component's effects whatever it returns, so without this the newer + // model's orders get a payment_method written behind the tree that is + // supposed to be inactive — and the API then drops + // available_payment_settings, flipping the order onto the older model for + // good. + if (paymentsModel === "payment_sessions") return if ( paymentMethods != null && !paymentSourceCreated && From 69b28459476d585d833fc116ea48b995cd845f75 Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:30:23 +0200 Subject: [PATCH 08/21] fix: let the privacy checkbox reach a payment_sessions place-order button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PrivacyAndTermsCheckbox announced a change through PlaceOrderContext in container mode and through a DOM event only in standalone mode. The newer place-order button uses neither context nor standalone detection, so under a (deprecated) — which mfe-checkout still mounts — it never heard the checkbox and stayed disabled for good. Dispatch the event in both modes. It is additive: the older branch keeps working through the context call, which still runs first. Two specs asserted the old "no event in container mode" contract and now assert the new one. Verified in the browser: ticking the checkbox enables the button on an order using the manual payment setting. Co-Authored-By: Claude Opus 5 (1M context) --- .../place-order-payments-model.spec.tsx | 28 +++++++++++++ .../specs/orders/place-order.spec.tsx | 41 ++++++++++++++++++- .../orders/PrivacyAndTermsCheckbox.tsx | 6 +++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/react-components/specs/orders/place-order-payments-model.spec.tsx b/packages/react-components/specs/orders/place-order-payments-model.spec.tsx index fa04d2ee..8dc20ffd 100644 --- a/packages/react-components/specs/orders/place-order-payments-model.spec.tsx +++ b/packages/react-components/specs/orders/place-order-payments-model.spec.tsx @@ -238,3 +238,31 @@ describe("PlaceOrderButtonPaymentSessions", () => { }) }) }) + +// The checkbox lives outside the button and talks to it through a DOM event. +// In container mode it used to notify only PlaceOrderContext, which the newer +// branch does not use — leaving the button disabled forever. +describe("privacy checkbox reaches the payment_sessions button", () => { + it("enables the button when the recheck event fires", async () => { + render( + + + + ) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true) + + await act(async () => { + localStorage.setItem("privacy-terms", "true") + window.dispatchEvent(new CustomEvent("cl:placeorder:recheck")) + }) + + await waitFor(() => { + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(false) + }) + }) +}) diff --git a/packages/react-components/specs/orders/place-order.spec.tsx b/packages/react-components/specs/orders/place-order.spec.tsx index b403e3e4..c56bc750 100644 --- a/packages/react-components/specs/orders/place-order.spec.tsx +++ b/packages/react-components/specs/orders/place-order.spec.tsx @@ -9,7 +9,7 @@ import CustomerContext from "#context/CustomerContext" import OrderContext, { defaultOrderContext } from "#context/OrderContext" import PaymentMethodContext, { defaultPaymentMethodContext } from "#context/PaymentMethodContext" import PlaceOrderContext, { defaultPlaceOrderContext } from "#context/PlaceOrderContext" -import { usePlaceOrder } from "#hooks/usePlaceOrder" +import { PLACE_ORDER_RECHECK_EVENT, usePlaceOrder } from "#hooks/usePlaceOrder" import { getAcceptedSnapshot, getCheckboxCount, @@ -799,6 +799,45 @@ describe("PrivacyAndTermsCheckbox (container mode)", () => { vi.mocked(getCardDetails).mockReturnValue({ brand: "" } as never) }) + // The event is dispatched in both modes. It is the only channel that reaches + // a `payment_sessions` place-order button, which does not subscribe to the + // acceptance store — and a consumer may still mount the deprecated + // above it, as mfe-checkout does. + it("dispatches PLACE_ORDER_RECHECK_EVENT in container mode too", async () => { + const handler = vi.fn() + window.addEventListener(PLACE_ORDER_RECHECK_EVENT, handler) + + render( + + + + + + ) + + await waitFor(() => { + expect(screen.getByRole("checkbox").getAttribute("disabled")).toBeNull() + }) + + await act(async () => { + fireEvent.click(screen.getByRole("checkbox")) + }) + + expect(handler).toHaveBeenCalled() + window.removeEventListener(PLACE_ORDER_RECHECK_EVENT, handler) + }) }) // --------------------------------------------------------------------------- diff --git a/packages/react-components/src/components/orders/PrivacyAndTermsCheckbox.tsx b/packages/react-components/src/components/orders/PrivacyAndTermsCheckbox.tsx index c3d7bd90..8c8c7a7b 100644 --- a/packages/react-components/src/components/orders/PrivacyAndTermsCheckbox.tsx +++ b/packages/react-components/src/components/orders/PrivacyAndTermsCheckbox.tsx @@ -1,6 +1,7 @@ import { type JSX, useContext, useEffect, useState } from "react" import CommerceLayerContext from "#context/CommerceLayerContext" import OrderContext from "#context/OrderContext" +import { PLACE_ORDER_RECHECK_EVENT } from "#hooks/usePlaceOrder" import { useTermsAndConditions } from "#hooks/useTermsAndConditions" import { useOrganizationConfig } from "#utils/organization" import { registerCheckbox } from "#utils/termsAcceptanceStore" @@ -19,6 +20,11 @@ export function PrivacyAndTermsCheckbox(props: Partial): JSX.Ele const handleChange = (e: React.ChangeEvent): void => { setAccepted((e.target as HTMLInputElement)?.checked) + // Announce the change through a DOM event too. The acceptance store above + // is subscribed to by the `payment_source` branch only, so a + // `payment_sessions` place-order button would never hear the checkbox and + // would stay disabled for good. The event is additive. + window.dispatchEvent(new CustomEvent(PLACE_ORDER_RECHECK_EVENT)) } useEffect(() => { From 292692e971d9a96dcb2b38c38ed0a9a7f65e0995 Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:42:12 +0200 Subject: [PATCH 09/21] refactor: move the Payments Model derivation into core-components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usePaymentsModel() held the rule itself, so an application's own data layer could not reach it — mfe-checkout had to copy the precedence logic into its provider, where a hook cannot be called. Two copies of a domain rule that must never disagree. getPaymentsModel(order) is now a plain function in core-components and the hook is a thin binding over it. This is the boundary the ADRs set out: domain logic without React in core-components, React on top. The derivation cases move to the function's own spec; the hook keeps only what proves the binding. Co-Authored-By: Claude Opus 5 (1M context) --- .../payment_sessions/getPaymentsModel.spec.ts | 56 +++++++++++++++++++ .../src/payment_sessions/getPaymentsModel.ts | 50 +++++++++++++++++ .../src/payment_sessions/index.ts | 2 + .../specs/hooks/usePaymentsModel.spec.tsx | 36 ++---------- .../src/hooks/usePaymentsModel.ts | 47 +++------------- 5 files changed, 122 insertions(+), 69 deletions(-) create mode 100644 packages/core-components/src/payment_sessions/getPaymentsModel.spec.ts create mode 100644 packages/core-components/src/payment_sessions/getPaymentsModel.ts diff --git a/packages/core-components/src/payment_sessions/getPaymentsModel.spec.ts b/packages/core-components/src/payment_sessions/getPaymentsModel.spec.ts new file mode 100644 index 00000000..66837158 --- /dev/null +++ b/packages/core-components/src/payment_sessions/getPaymentsModel.spec.ts @@ -0,0 +1,56 @@ +import type { Order } from "@commercelayer/sdk" +import { describe, expect, it } from "vitest" +import { getPaymentsModel } from "./getPaymentsModel" + +const SETTING = { id: "ps-1", type: "payment_setting_manuals" } +const METHOD = { id: "pm-1", payment_source_type: "stripe_payments" } + +function order(overrides: Partial = {}): Order { + return { id: "order-1", type: "orders", ...overrides } as Order +} + +describe("getPaymentsModel", () => { + it("is undetermined without an order", () => { + expect(getPaymentsModel(undefined)).toBe("undetermined") + expect(getPaymentsModel(null)).toBe("undetermined") + }) + + it("reads payment_sessions from available_payment_settings", () => { + expect(getPaymentsModel(order({ available_payment_settings: [SETTING] } as never))).toBe( + "payment_sessions" + ) + }) + + it("reads payment_source from available_payment_methods", () => { + expect(getPaymentsModel(order({ available_payment_methods: [METHOD] } as never))).toBe( + "payment_source" + ) + }) + + // 2026-05 is additive, so both arrays can arrive together. The newer model + // wins, and that precedence belongs to the library, not to its consumers. + it("prefers payment_sessions when the order carries both", () => { + expect( + getPaymentsModel( + order({ + available_payment_settings: [SETTING], + available_payment_methods: [METHOD], + } as never) + ) + ).toBe("payment_sessions") + }) + + // An empty array is not a model: an order with nothing configured must not + // be routed to either tree. + it("is undetermined when both arrays are empty", () => { + expect( + getPaymentsModel( + order({ available_payment_settings: [], available_payment_methods: [] } as never) + ) + ).toBe("undetermined") + }) + + it("is undetermined when the order carries neither relationship", () => { + expect(getPaymentsModel(order())).toBe("undetermined") + }) +}) diff --git a/packages/core-components/src/payment_sessions/getPaymentsModel.ts b/packages/core-components/src/payment_sessions/getPaymentsModel.ts new file mode 100644 index 00000000..f4c94cf6 --- /dev/null +++ b/packages/core-components/src/payment_sessions/getPaymentsModel.ts @@ -0,0 +1,50 @@ +import type { Order } from "@commercelayer/sdk" + +/** + * Which of the two mutually exclusive payment models an order uses. + * + * An order is bound to one model for its whole life and can never switch. The + * values are named after the order relationship that carries the payment, not + * after their age — "legacy" and "new" age badly, and "v1/v2" collides with the + * API version, which is a different thing entirely. + */ +export type PaymentsModel = + /** `payment_gateways` + `payment_methods` + one payment source per order. */ + | "payment_source" + /** `payment_settings` + `payment_sessions`. */ + | "payment_sessions" + /** The order data needed to decide has not loaded yet. */ + | "undetermined" + +/** + * Derive the Payments Model from an order. + * + * API version `2026-05` is purely additive, so a single response can carry + * **both** `available_payment_methods` and `available_payment_settings`. When + * it does, the newer model wins and the older flow is excluded entirely. + * + * That precedence lives here, in the library, and never in the consuming + * application. Pushing it out — for instance by having the app blank out + * `available_payment_methods` after fetching — turns a domain rule into + * something every consumer has to remember, and mutates an API response to make + * components behave. + * + * Pure and React-free on purpose: an application's own data layer needs the + * same answer as the components do, and a hook cannot be called from outside a + * component. `usePaymentsModel()` in `@commercelayer/react-components` is a + * thin wrapper over this. + * + * Requires the order to have been fetched **with** `available_payment_settings` + * in its `include`. Without it, an absent array is indistinguishable from one + * that was never requested and this silently reports the wrong model. + * + * @returns the model, and `"undetermined"` when the order is missing or carries + * neither relationship. That third state is real and observable, not a + * transient detail: every caller has to handle it. + */ +export function getPaymentsModel(order?: Order | null): PaymentsModel { + if (order == null) return "undetermined" + if ((order.available_payment_settings ?? []).length > 0) return "payment_sessions" + if ((order.available_payment_methods ?? []).length > 0) return "payment_source" + return "undetermined" +} diff --git a/packages/core-components/src/payment_sessions/index.ts b/packages/core-components/src/payment_sessions/index.ts index f784a690..f123a59d 100644 --- a/packages/core-components/src/payment_sessions/index.ts +++ b/packages/core-components/src/payment_sessions/index.ts @@ -1,6 +1,8 @@ export { createPaymentSession } from "./createPaymentSession" export { findCurrentPaymentSession } from "./findCurrentPaymentSession" export { findReusablePaymentSession } from "./findReusablePaymentSession" +export type { PaymentsModel } from "./getPaymentsModel" +export { getPaymentsModel } from "./getPaymentsModel" export { mapPlaceabilityErrors } from "./mapPlaceabilityErrors" export type { PlaceOrderWithPaymentSessionsResult } from "./placeOrderWithPaymentSessions" export { diff --git a/packages/react-components/specs/hooks/usePaymentsModel.spec.tsx b/packages/react-components/specs/hooks/usePaymentsModel.spec.tsx index 83899080..7290a725 100644 --- a/packages/react-components/specs/hooks/usePaymentsModel.spec.tsx +++ b/packages/react-components/specs/hooks/usePaymentsModel.spec.tsx @@ -16,29 +16,23 @@ function wrapper(order?: Partial | null) { const SETTING = { id: "ps-1", type: "payment_setting_manuals" } const METHOD = { id: "pm-1", payment_source_type: "stripe_payments" } +// The derivation rules themselves are covered by getPaymentsModel's own spec +// in core-components. What is left to prove here is the binding: that the hook +// reads OrderContext and reports what that function says. describe("usePaymentsModel", () => { it("is undetermined until the order has loaded", () => { const { result } = renderHook(() => usePaymentsModel(), { wrapper: wrapper(null) }) expect(result.current).toBe("undetermined") }) - it("reads payment_sessions from available_payment_settings", () => { + it("reports the model of the order in context", () => { const { result } = renderHook(() => usePaymentsModel(), { wrapper: wrapper({ id: "order-1", available_payment_settings: [SETTING] } as never), }) expect(result.current).toBe("payment_sessions") }) - it("reads payment_source from available_payment_methods", () => { - const { result } = renderHook(() => usePaymentsModel(), { - wrapper: wrapper({ id: "order-1", available_payment_methods: [METHOD] } as never), - }) - expect(result.current).toBe("payment_source") - }) - - // API version 2026-05 is additive, so both arrays can arrive together. The - // newer model wins, and the precedence lives here rather than in the app. - it("prefers payment_sessions when the order carries both", () => { + it("applies the precedence rule through the shared function", () => { const { result } = renderHook(() => usePaymentsModel(), { wrapper: wrapper({ id: "order-1", @@ -48,24 +42,4 @@ describe("usePaymentsModel", () => { }) expect(result.current).toBe("payment_sessions") }) - - // An empty array is not a model: an order that simply has nothing configured - // must not be routed to either tree. - it("is undetermined when both arrays are empty", () => { - const { result } = renderHook(() => usePaymentsModel(), { - wrapper: wrapper({ - id: "order-1", - available_payment_settings: [], - available_payment_methods: [], - } as never), - }) - expect(result.current).toBe("undetermined") - }) - - it("is undetermined when the order carries neither relationship", () => { - const { result } = renderHook(() => usePaymentsModel(), { - wrapper: wrapper({ id: "order-1" } as never), - }) - expect(result.current).toBe("undetermined") - }) }) diff --git a/packages/react-components/src/hooks/usePaymentsModel.ts b/packages/react-components/src/hooks/usePaymentsModel.ts index 3a08988b..1a850803 100644 --- a/packages/react-components/src/hooks/usePaymentsModel.ts +++ b/packages/react-components/src/hooks/usePaymentsModel.ts @@ -1,54 +1,25 @@ +import { getPaymentsModel, type PaymentsModel } from "@commercelayer/core-components" import { useContext, useMemo } from "react" import OrderContext from "#context/OrderContext" -/** - * Which of the two mutually exclusive payment models an order uses. - * - * An order is bound to one model for its whole life and can never switch. The - * values are named after the order relationship that carries the payment, not - * after their age — "legacy" and "new" age badly, and "v1/v2" collides with the - * API version, which is a different thing entirely. - */ -export type PaymentsModel = - /** `payment_gateways` + `payment_methods` + one payment source per order. */ - | "payment_source" - /** `payment_settings` + `payment_sessions`. */ - | "payment_sessions" - /** The order data needed to decide has not loaded yet. */ - | "undetermined" +export type { PaymentsModel } /** - * Derive the Payments Model from the order. + * Derive the Payments Model from the order in `OrderContext`. * - * API version `2026-05` is purely additive, so a single response can carry - * **both** `available_payment_methods` and `available_payment_settings`. When - * it does, the newer model wins and the older flow is excluded entirely. - * - * That precedence lives here, in the library, and never in the consuming - * application. Pushing it out — for instance by having the app blank out - * `available_payment_methods` after fetching — turns a domain rule into - * something every consumer has to remember, and mutates an API response to make - * components behave. + * The rule itself — including the precedence of `available_payment_settings` + * over `available_payment_methods` — lives in `getPaymentsModel`, so an + * application's own data layer reaches the same answer without a hook. See + * that function for the reasoning; this is only the React binding. * * The derivation is pure: it never fetches and holds no state of its own, so it * cannot drift from the order it describes. It relies on `` having * registered `available_payment_settings` in the order include, which it does - * for every consumer — without that, an absent array would be indistinguishable - * from one that was never requested. - * - * @returns the model, and `"undetermined"` until the order is loaded. That - * third state is real and observable, not a transient detail: every caller has - * to render something sensible for it. + * for every consumer. */ export function usePaymentsModel(): PaymentsModel { const { order } = useContext(OrderContext) - - return useMemo(() => { - if (order == null) return "undetermined" - if ((order.available_payment_settings ?? []).length > 0) return "payment_sessions" - if ((order.available_payment_methods ?? []).length > 0) return "payment_source" - return "undetermined" - }, [order]) + return useMemo(() => getPaymentsModel(order), [order]) } export default usePaymentsModel From d6c471c17afc7d872e46d1ff6d4ed07b32930943 Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:52:14 +0200 Subject: [PATCH 10/21] docs: record the shared error state as debt due with the second setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit holds one errors state for the whole list while isPending is per-setting. Invisible today with only the manual setting implemented; the moment a second exists, a Stripe selection error renders under the manual entry. Filed next to the implementation table rather than as its own ADR: that table is what the person adding the second setting reads, and that is exactly when this bites. Also record why must stay mounted on the older model — it registers the payment-session includes, which has to happen before the order is fetched. Co-Authored-By: Claude Opus 5 (1M context) --- .../adr/2026-08-18-payment-session-lifecycle.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/adr/2026-08-18-payment-session-lifecycle.md b/docs/adr/2026-08-18-payment-session-lifecycle.md index 15250b59..cfdfc32a 100644 --- a/docs/adr/2026-08-18-payment-session-lifecycle.md +++ b/docs/adr/2026-08-18-payment-session-lifecycle.md @@ -188,6 +188,23 @@ checkout with **no payment options and no explanation**. A development-only `con fires whenever `` skips a setting; it is not public API and should be removed once the table below is complete. +`` must stay mounted even when the order turns out to be on the older +model. It registers the payment-session includes, and that has to happen *before* the order +is fetched: adding an include afterwards does not trigger a refetch, so a component mounted +only once the model is known would never receive its data. Consumers that mount it +conditionally will see an order whose `payment_sessions` never expand. + +### Known debt, due with the second setting + +`` holds **one** `errors` state for the whole list, not one per setting, +while `isPending` is correctly per-setting. With only the manual setting implemented this +is invisible; the moment a second one exists, an error raised while selecting Stripe will +render under the manual entry too. + +Not fixed yet because the clean fix is a `` component, and a public +component invented for a problem no one can observe is harder to remove than to add. +Whoever implements the second setting should fix this first. + ### Payment Setting implementation status | Setting | Type literal | Status | From 96c5c70406cebc93a70779d9165bf8cd974da81a Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:11:32 +0200 Subject: [PATCH 11/21] docs: record why single-setting auto-selection is deferred MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit has autoSelectSinglePaymentMethod and the symmetric prop belongs on , but the obvious condition is a trap: the component renders only implemented settings, so "one entry rendered" is not "one option offered". Today that would commit shoppers to bank transfer while the card options the organization pays for stay invisible. Under the correct condition — one entry in the raw available_payment_settings — it cannot fire on any organization that still has unimplemented settings, so writing it now means shipping it untested. Deferred to the second setting. Also record the observation behind it: creating a session drops available_payment_methods to zero, the mirror of what writing payment_method does to available_payment_settings. Whichever tree acts first commits the order, which is why neither may act without the shopper. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-18-payment-session-lifecycle.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/adr/2026-08-18-payment-session-lifecycle.md b/docs/adr/2026-08-18-payment-session-lifecycle.md index cfdfc32a..54b16295 100644 --- a/docs/adr/2026-08-18-payment-session-lifecycle.md +++ b/docs/adr/2026-08-18-payment-session-lifecycle.md @@ -205,6 +205,27 @@ Not fixed yet because the clean fix is a `` component, and component invented for a problem no one can observe is harder to remove than to add. Whoever implements the second setting should fix this first. +**Auto-selecting a single setting is deliberately absent.** `` offers +`autoSelectSinglePaymentMethod` and the symmetric prop belongs here, but the obvious +condition is a trap: `` renders only the *implemented* settings, so "one +entry rendered" is not "one option offered". An organization with five settings configured +and one implemented would have its shoppers silently committed to bank transfer while the +card options it pays for stay invisible — and that is the situation for every organization +until the table below is complete. + +The condition has to be `available_payment_settings.length === 1` on the raw array, plus +the setting being implemented and no live session already on the order. Deferred rather +than written blind: under the correct condition it cannot fire on any organization that +still has unimplemented settings, so it would ship untested against the real API. Pick it +up with the second setting, when the rendered list and the real one start to converge. + +Note that selecting is not a neutral gesture on either model. Creating a Payment Session +makes `new_payments_engaged?` true and the API then stops serving +`available_payment_methods` — observed: an order went from one available method to zero the +moment its first session existed. The mirror of what writing `payment_method` does to +`available_payment_settings`. Whichever tree acts first commits the order for good, which +is precisely why neither may act without the shopper. + ### Payment Setting implementation status | Setting | Type literal | Status | From df71b9e139953f42a67ee7a7fbeb29488d4c5a7a Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:33:17 +0200 Subject: [PATCH 12/21] docs: flag the place-order timeout UX as needing its own grilling session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reporting the last refusal tells someone whose payment is still settling that something went wrong. The direction to explore is a thank-you page with a payment-status summary and a refresh, re-offering the payment components when something did fail — but it reaches into checkout navigation and post-placement behaviour, so it needs its own questions rather than a decision made in passing. Records the interim rule too: on timeout nothing is rolled back, so a gift card authorized during a timed-out attempt stays bound to the order and loses its remove control. Co-Authored-By: Claude Opus 5 (1M context) --- ...-18-place-order-split-by-payments-model.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/adr/2026-08-18-place-order-split-by-payments-model.md b/docs/adr/2026-08-18-place-order-split-by-payments-model.md index aec2ae54..572a36bf 100644 --- a/docs/adr/2026-08-18-place-order-split-by-payments-model.md +++ b/docs/adr/2026-08-18-place-order-split-by-payments-model.md @@ -171,3 +171,22 @@ failed job never retries and the authorization stays `pending` **forever**. Exha attempts is therefore a reachable steady state, not just a slow path. It is reported as a recoverable condition inviting another attempt — never as a payment failure, and never by deleting anything, since the payment may in fact have succeeded. + +### Open: what a timeout should actually show the shopper + +Reporting the last refusal is a placeholder, not a considered answer. It tells someone whose +payment is still settling that something went wrong. + +The direction to explore, in a grilling session of its own: always land on a thank-you page +carrying a payment-status summary and a refresh control, and — when a payment did fail — +re-offer the payment components there so the shopper can settle the remainder and the order +finally reads as paid. That reframes the timeout as "we are still checking" rather than an +error, which is what it actually is. + +Not designed here because it reaches past this component into checkout navigation and into +what happens after placement, and deserves its own set of questions. + +Until then: on timeout, nothing is rolled back. A gift card authorized during a timed-out +attempt stays authorized and bound to the order, and its remove control disappears — +the money has been taken and only a refund could return it, which this iteration does not +implement. From cf3f99748767b148348cf75f11a32c91cf44e6e1 Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:32:45 +0200 Subject: [PATCH 13/21] feat(core): gift card payment sessions as domain operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the gift card iteration: the pure derivations and the API operations, with no React involved. derivePaymentSessionsState is the single source for every number the payment UI needs. The remainder is derived rather than read from order.session_amount_cents, which does not move until a session is authorized — and gift cards are authorized at place time, so the server's figure stays at the full total for as long as the shopper is choosing. What is summed are the amounts the server itself computed per session. applyGiftCard sends an explicit amount_cents from the second card onwards. Without it the server sizes each card against a remainder that has not moved: on a 7100 order a 2000 card followed by a 5000 one produces sessions of 2000 and 7100 — 9100 of credit for a 7100 order, and nothing server-side prevents it. Sending an amount is safe because the server clamps it down, never up. The "never send amount_cents" rule still holds for the session paying the difference. (Note the playground has this bug; it is not a precedent.) Applying or removing a gift card also deletes the session paying the difference: amount_cents is immutable, so once the remainder moves that session is not stale but wrong. Bound to the action that causes it rather than to an effect comparing amounts. Only sessions that have taken nothing are deleted — the API refuses otherwise and surfaces the refusal as an unhandled 500, and a sales-channel token cannot delete an authorization. A charged gift card can only be refunded, which this iteration does not implement. placeOrderWithPaymentSessions now takes the order and authorizes N gift cards before the optional session paying the difference, stopping at the first failure. Gift cards are excluded from findCurrentPaymentSession: they are additive, not one of the alternatives a radio group picks between. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/payment_sessions/applyGiftCard.ts | 65 +++++ .../derivePaymentSessionsState.spec.ts | 172 ++++++++++++++ .../derivePaymentSessionsState.ts | 96 ++++++++ .../findCurrentPaymentSession.ts | 9 +- .../giftCardOperations.spec.ts | 222 ++++++++++++++++++ .../src/payment_sessions/index.ts | 8 + .../invalidateCurrentPaymentSession.ts | 45 ++++ .../placeOrderWithPaymentSessions.spec.ts | 192 ++++++++++----- .../placeOrderWithPaymentSessions.ts | 111 +++++---- .../src/payment_sessions/removeGiftCard.ts | 50 ++++ .../src/payment_sessions/types.ts | 32 +++ .../place-order-payments-model.spec.tsx | 9 +- .../PlaceOrderButtonPaymentSessions.tsx | 14 +- 13 files changed, 904 insertions(+), 121 deletions(-) create mode 100644 packages/core-components/src/payment_sessions/applyGiftCard.ts create mode 100644 packages/core-components/src/payment_sessions/derivePaymentSessionsState.spec.ts create mode 100644 packages/core-components/src/payment_sessions/derivePaymentSessionsState.ts create mode 100644 packages/core-components/src/payment_sessions/giftCardOperations.spec.ts create mode 100644 packages/core-components/src/payment_sessions/invalidateCurrentPaymentSession.ts create mode 100644 packages/core-components/src/payment_sessions/removeGiftCard.ts diff --git a/packages/core-components/src/payment_sessions/applyGiftCard.ts b/packages/core-components/src/payment_sessions/applyGiftCard.ts new file mode 100644 index 00000000..c3f5c0e1 --- /dev/null +++ b/packages/core-components/src/payment_sessions/applyGiftCard.ts @@ -0,0 +1,65 @@ +import type { Order, PaymentSession } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" +import { derivePaymentSessionsState } from "./derivePaymentSessionsState" +import { invalidateCurrentPaymentSession } from "./invalidateCurrentPaymentSession" + +interface ApplyGiftCardParams extends Pick { + /** The order as last fetched. Its sessions decide the amount to ask for. */ + order: Order + giftCardCode: string +} + +/** + * Spend a gift card on an order by creating a Payment Session for it. + * + * **Why an explicit `amount_cents` from the second card onwards.** The server + * sizes a gift card session as `min(order.session_amount_cents, balance)`, and + * `session_amount_cents` only drops once a session is *authorized*. Gift cards + * are authorized at place time, so while the shopper is still choosing the + * server sees the full total: a second card would be sized for the whole order + * rather than the difference. On a $71 order, a $50 card followed by a $100 card + * would produce sessions of $50 and $71 — $121 of credit for a $71 order, with + * nothing server-side to stop it. + * + * Sending an amount is safe because `cap_amount_cents` clamps it *down* to what + * the server would have allowed and never up, so the server stays the authority + * on the maximum. And the number sent is a sum of amounts the server itself + * computed for the earlier sessions — not a reimplementation of its rules. + * + * This is the one place the "never send `amount_cents`" rule is broken. It still + * holds for the session paying the difference, where the server must be the one + * to work out what is left. + * + * Applying a gift card also **invalidates the session paying the difference**: + * its `amount_cents` is immutable, so the moment the remainder changes that + * session is dead. Both happen here, as one operation, because binding the + * invalidation to the action that causes it is what makes it deterministic — + * rather than an effect somewhere comparing amounts. + */ +export async function applyGiftCard({ + accessToken, + interceptors, + order, + giftCardCode, +}: ApplyGiftCardParams): Promise { + const sdk = getSdk({ accessToken, interceptors }) + const state = derivePaymentSessionsState(order) + + if (state.giftCardSettingId == null) { + throw new Error("This order has no gift card payment setting available.") + } + + const session = await sdk.payment_sessions.create({ + gift_card_code: giftCardCode, + payment_setting: sdk.payment_settings.relationship(state.giftCardSettingId), + order: sdk.orders.relationship(order.id), + // Omitted for the first card so the server sizes it against the balance; + // supplied afterwards because the server's own remainder has not moved. + ...(state.giftCardSessions.length > 0 ? { amount_cents: state.remainingAmountCents } : {}), + }) + + await invalidateCurrentPaymentSession({ accessToken, interceptors, order }) + + return session +} diff --git a/packages/core-components/src/payment_sessions/derivePaymentSessionsState.spec.ts b/packages/core-components/src/payment_sessions/derivePaymentSessionsState.spec.ts new file mode 100644 index 00000000..feb0f3f7 --- /dev/null +++ b/packages/core-components/src/payment_sessions/derivePaymentSessionsState.spec.ts @@ -0,0 +1,172 @@ +import type { Order, PaymentSession } from "@commercelayer/sdk" +import { describe, expect, it } from "vitest" +import { derivePaymentSessionsState } from "./derivePaymentSessionsState" + +const MANUAL = { id: "ps-manual", type: "payment_setting_manuals" } +const GIFT_CARD = { id: "ps-gift", type: "payment_setting_gift_cards" } +const TOTAL = 7100 + +function session(overrides: Partial = {}): PaymentSession { + return { + id: "session-1", + type: "payment_sessions", + status: "unpaid", + payment_setting: MANUAL, + ...overrides, + } as PaymentSession +} + +function giftCard(id: string, amountCents: number, overrides: Partial = {}) { + return session({ + id, + amount_cents: amountCents, + payment_setting: GIFT_CARD as never, + ...overrides, + }) +} + +function order(sessions: PaymentSession[], settings = [MANUAL, GIFT_CARD]): Order { + return { + id: "order-1", + type: "orders", + total_amount_with_taxes_cents: TOTAL, + payment_sessions: sessions, + available_payment_settings: settings, + } as Order +} + +describe("derivePaymentSessionsState", () => { + it("reports nothing paid for an order with no sessions", () => { + const state = derivePaymentSessionsState(order([])) + expect(state).toMatchObject({ + giftCardSessions: [], + giftCardAmountCents: 0, + remainingAmountCents: TOTAL, + isCovered: false, + canAddGiftCard: true, + giftCardSettingId: "ps-gift", + }) + expect(state.currentPaymentSession).toBeUndefined() + }) + + it("handles a missing order", () => { + const state = derivePaymentSessionsState(undefined) + expect(state.remainingAmountCents).toBe(0) + expect(state.isCovered).toBe(true) + }) + + // The point of this derivation: the server's own remainder does not move + // until a session is authorized, and gift cards are authorized at place time. + it("counts an applied gift card before it is authorized", () => { + const state = derivePaymentSessionsState(order([giftCard("gift-a", 2000)])) + expect(state.giftCardAmountCents).toBe(2000) + expect(state.remainingAmountCents).toBe(5100) + expect(state.isCovered).toBe(false) + }) + + it("sums several gift cards", () => { + const state = derivePaymentSessionsState( + order([giftCard("gift-a", 2000), giftCard("gift-b", 1500)]) + ) + expect(state.giftCardSessions.map((s) => s.id)).toEqual(["gift-a", "gift-b"]) + expect(state.remainingAmountCents).toBe(3600) + }) + + it("reports full coverage when gift cards reach the total", () => { + const state = derivePaymentSessionsState(order([giftCard("gift-a", TOTAL)])) + expect(state.remainingAmountCents).toBe(0) + expect(state.isCovered).toBe(true) + expect(state.canAddGiftCard).toBe(false) + }) + + it("never reports a negative remainder", () => { + const state = derivePaymentSessionsState(order([giftCard("gift-a", TOTAL + 5000)])) + expect(state.remainingAmountCents).toBe(0) + }) + + // A burnt or refunded card took no money; showing it would tell the shopper a + // payment is in place when none is. + it.each(["declined", "failed", "canceled", "expired"])( + "drops a gift card whose authorization is %s", + (status) => { + const state = derivePaymentSessionsState( + order([giftCard("gift-a", 2000, { payment_authorization: { status } as never })]) + ) + expect(state.giftCardSessions).toEqual([]) + expect(state.remainingAmountCents).toBe(TOTAL) + } + ) + + it("drops a refunded gift card", () => { + const state = derivePaymentSessionsState( + order([giftCard("gift-a", 2000, { payment_refunds: [{ id: "refund-1" }] as never })]) + ) + expect(state.giftCardSessions).toEqual([]) + }) + + it("keeps a gift card whose authorization is still in flight", () => { + const state = derivePaymentSessionsState( + order([giftCard("gift-a", 2000, { payment_authorization: { status: "pending" } as never })]) + ) + expect(state.giftCardSessions.map((s) => s.id)).toEqual(["gift-a"]) + }) + + describe("the session paying the difference", () => { + it("is the current selection, and is not a gift card", () => { + const state = derivePaymentSessionsState( + order([giftCard("gift-a", 2000), session({ id: "method", amount_cents: 5100 })]) + ) + expect(state.currentPaymentSession?.id).toBe("method") + }) + + // An unauthorized method session is an intent, not a payment: counting it + // would hide the fact that nothing has been taken. + it("does not reduce the remainder until it is authorized", () => { + const state = derivePaymentSessionsState(order([session({ amount_cents: 7100 })])) + expect(state.remainingAmountCents).toBe(TOTAL) + }) + + it("reduces the remainder once authorized", () => { + const state = derivePaymentSessionsState( + order([ + session({ + amount_cents: 5100, + payment_authorization: { status: "succeeded" } as never, + }), + giftCard("gift-a", 2000, { payment_authorization: { status: "succeeded" } as never }), + ]) + ) + expect(state.remainingAmountCents).toBe(0) + expect(state.isCovered).toBe(true) + }) + }) + + describe("canAddGiftCard", () => { + // Settling a partially-paid order is a flow this iteration does not + // implement, so once money is taken or in flight nothing more is accepted. + it("is false once any session has been authorized", () => { + const state = derivePaymentSessionsState( + order([giftCard("gift-a", 1000, { payment_authorization: { status: "pending" } as never })]) + ) + expect(state.remainingAmountCents).toBe(6100) + expect(state.canAddGiftCard).toBe(false) + }) + + it("is true while everything is still unauthorized and something is owed", () => { + const state = derivePaymentSessionsState(order([giftCard("gift-a", 1000)])) + expect(state.canAddGiftCard).toBe(true) + }) + + it("is unaffected by a failed authorization", () => { + const state = derivePaymentSessionsState( + order([giftCard("gift-a", 1000, { payment_authorization: { status: "failed" } as never })]) + ) + expect(state.canAddGiftCard).toBe(true) + }) + }) + + it("reports no gift card setting when the order has none available", () => { + const state = derivePaymentSessionsState(order([], [MANUAL])) + expect(state.giftCardSettingId).toBeUndefined() + }) +}) diff --git a/packages/core-components/src/payment_sessions/derivePaymentSessionsState.ts b/packages/core-components/src/payment_sessions/derivePaymentSessionsState.ts new file mode 100644 index 00000000..4faa4eff --- /dev/null +++ b/packages/core-components/src/payment_sessions/derivePaymentSessionsState.ts @@ -0,0 +1,96 @@ +import type { Order, PaymentSession } from "@commercelayer/sdk" +import { findCurrentPaymentSession } from "./findCurrentPaymentSession" +import { GIFT_CARD_SETTING_TYPE, hasLiveAuthorization, isGiftCardSession } from "./types" + +export interface PaymentSessionsState { + /** + * Gift cards the shopper has applied and can still see, newest last. Excludes + * any whose authorization failed or that have been refunded: those took no + * money and showing them would tell the shopper a payment is in place when + * none is. + */ + giftCardSessions: PaymentSession[] + /** Sum of the applied gift cards, at face value. */ + giftCardAmountCents: number + /** + * What the shopper still has to pay. + * + * Derived here rather than read from `order.session_amount_cents`, which does + * not move until a session is authorized — and gift cards are authorized at + * place time, so the server's number stays at the full total for the whole + * time the shopper is choosing. The amounts summed are the ones the server + * computed for each session, so this is arithmetic over server values, not a + * reimplementation of its rules. + */ + remainingAmountCents: number + /** True when nothing is left to pay, so no other payment method is needed. */ + isCovered: boolean + /** + * Whether another gift card may be applied. + * + * False once anything has been authorized: money is taken or in flight, and + * settling a partially-paid order is a flow this iteration does not + * implement. See the place-order ADR. + */ + canAddGiftCard: boolean + /** The non-gift-card session paying the difference, if the shopper picked one. */ + currentPaymentSession?: PaymentSession + /** The gift card Payment Setting, when the order has one available. */ + giftCardSettingId?: string +} + +/** + * Everything the payment UI needs to know about an order's Payment Sessions, + * in one place. + * + * One source deliberately: the remaining amount drives the order total, whether + * the method selector renders at all, whether the gift card input accepts + * another code, and whether a consuming application still considers payment + * required. Deriving it in more than one place is how those four end up + * disagreeing. + */ +export function derivePaymentSessionsState(order?: Order | null): PaymentSessionsState { + const sessions = order?.payment_sessions ?? [] + const total = order?.total_amount_with_taxes_cents ?? 0 + + const giftCardSessions = sessions.filter( + (session) => isGiftCardSession(session) && isLiveGiftCard(session) + ) + const giftCardAmountCents = sumAmounts(giftCardSessions) + + // A method session reduces what is left only once it has taken money — an + // unauthorized one is just an intent. Gift cards count as soon as applied, + // which is the whole reason this derivation exists. + const takenMethodAmountCents = sumAmounts( + sessions.filter((session) => !isGiftCardSession(session) && hasLiveAuthorization(session)) + ) + + const remainingAmountCents = Math.max(0, total - giftCardAmountCents - takenMethodAmountCents) + + return { + giftCardSessions, + giftCardAmountCents, + remainingAmountCents, + isCovered: remainingAmountCents === 0, + canAddGiftCard: remainingAmountCents > 0 && !sessions.some(hasLiveAuthorization), + currentPaymentSession: findCurrentPaymentSession({ paymentSessions: sessions }), + giftCardSettingId: (order?.available_payment_settings ?? []).find( + (setting) => setting.type === GIFT_CARD_SETTING_TYPE + )?.id, + } +} + +/** + * A gift card session still worth showing: nothing failed and nothing was given + * back. A refunded one is history, not an applied card. + */ +function isLiveGiftCard(session: PaymentSession): boolean { + if ((session.payment_refunds ?? []).length > 0) return false + const status = session.payment_authorization?.status + if (status == null) return true + return hasLiveAuthorization(session) +} + +function sumAmounts(sessions: PaymentSession[]): number { + return sessions.reduce((total, session) => total + (session.amount_cents ?? 0), 0) +} diff --git a/packages/core-components/src/payment_sessions/findCurrentPaymentSession.ts b/packages/core-components/src/payment_sessions/findCurrentPaymentSession.ts index 895f3ebe..15134b07 100644 --- a/packages/core-components/src/payment_sessions/findCurrentPaymentSession.ts +++ b/packages/core-components/src/payment_sessions/findCurrentPaymentSession.ts @@ -1,5 +1,5 @@ import type { PaymentSession } from "@commercelayer/sdk" -import { TERMINAL_FAILURE_TRANSACTION_STATUSES } from "./types" +import { isGiftCardSession, TERMINAL_FAILURE_TRANSACTION_STATUSES } from "./types" interface FindCurrentPaymentSessionParams { paymentSessions?: PaymentSession[] | null @@ -25,6 +25,12 @@ interface FindCurrentPaymentSessionParams { * shopper has to try again, and showing it as the current selection would tell * them a payment is in place when none is. * + * **Gift card sessions are excluded entirely.** A gift card is not one of the + * alternatives the shopper picks between — it is additive, applied on top, and + * an order may carry several at once. Including them here would put more than + * one session in a group that has room for exactly one. They are read through + * `derivePaymentSessionsState` instead. + * * **The selection is single, and it is the most recent session.** Switching * setting leaves the previous session on the order — it is never deleted, both * because an inert `unpaid` session costs nothing and because a sales-channel @@ -37,6 +43,7 @@ export function findCurrentPaymentSession({ paymentSettingId, }: FindCurrentPaymentSessionParams): PaymentSession | undefined { const live = (paymentSessions ?? []).filter((session) => { + if (isGiftCardSession(session)) return false if (paymentSettingId != null && session.payment_setting?.id !== paymentSettingId) return false const authorizationStatus = session.payment_authorization?.status if (authorizationStatus == null) return true diff --git a/packages/core-components/src/payment_sessions/giftCardOperations.spec.ts b/packages/core-components/src/payment_sessions/giftCardOperations.spec.ts new file mode 100644 index 00000000..c8303dfd --- /dev/null +++ b/packages/core-components/src/payment_sessions/giftCardOperations.spec.ts @@ -0,0 +1,222 @@ +import type { Order, PaymentSession } from "@commercelayer/sdk" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { applyGiftCard } from "./applyGiftCard" +import { removeGiftCard } from "./removeGiftCard" + +const { getSdkMock } = vi.hoisted(() => ({ getSdkMock: vi.fn() })) +vi.mock("#sdk", () => ({ getSdk: getSdkMock })) + +const ACCESS_TOKEN = "token" +const MANUAL = { id: "ps-manual", type: "payment_setting_manuals" } +const GIFT_CARD = { id: "ps-gift", type: "payment_setting_gift_cards" } +const TOTAL = 7100 + +function session(overrides: Partial = {}): PaymentSession { + return { + id: "session-1", + type: "payment_sessions", + status: "unpaid", + payment_setting: MANUAL, + ...overrides, + } as PaymentSession +} + +function giftCard(id: string, amountCents: number, overrides: Partial = {}) { + return session({ + id, + amount_cents: amountCents, + payment_setting: GIFT_CARD as never, + ...overrides, + }) +} + +function order(sessions: PaymentSession[], settings = [MANUAL, GIFT_CARD]): Order { + return { + id: "order-1", + type: "orders", + total_amount_with_taxes_cents: TOTAL, + payment_sessions: sessions, + available_payment_settings: settings, + } as Order +} + +interface SdkStub { + create: ReturnType + del: ReturnType +} + +function stubSdk(): SdkStub { + const stub: SdkStub = { + create: vi.fn().mockResolvedValue({ id: "gift-new" }), + del: vi.fn().mockResolvedValue(undefined), + } + getSdkMock.mockReturnValue({ + payment_sessions: { create: stub.create, delete: stub.del }, + payment_settings: { relationship: vi.fn((id: string) => ({ id, type: "payment_settings" })) }, + orders: { relationship: vi.fn((id: string) => ({ id, type: "orders" })) }, + }) + return stub +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("applyGiftCard", () => { + // The server sizes the first card against its own balance, which is what we + // want — it knows the balance and we do not. + it("omits amount_cents for the first gift card", async () => { + const sdk = stubSdk() + + await applyGiftCard({ accessToken: ACCESS_TOKEN, order: order([]), giftCardCode: "ABC" }) + + expect(sdk.create).toHaveBeenCalledOnce() + const payload = sdk.create.mock.calls[0]?.[0] + expect(payload.gift_card_code).toBe("ABC") + expect(payload).not.toHaveProperty("amount_cents") + }) + + // The decisive case. The server's remainder has not moved — the first card is + // not authorized yet — so left to itself it would size this one for the whole + // order. On a 7100 order with 2000 already applied, that would be 7100 again: + // 9100 of credit for a 7100 order, with nothing server-side to stop it. + it("sends the real remainder from the second gift card onwards", async () => { + const sdk = stubSdk() + + await applyGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([giftCard("gift-a", 2000)]), + giftCardCode: "DEF", + }) + + expect(sdk.create.mock.calls[0]?.[0].amount_cents).toBe(5100) + }) + + it("accounts for every applied card when computing the remainder", async () => { + const sdk = stubSdk() + + await applyGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([giftCard("gift-a", 2000), giftCard("gift-b", 1500)]), + giftCardCode: "GHI", + }) + + expect(sdk.create.mock.calls[0]?.[0].amount_cents).toBe(3600) + }) + + it("refuses when the order has no gift card setting", async () => { + stubSdk() + + await expect( + applyGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([], [MANUAL]), + giftCardCode: "ABC", + }) + ).rejects.toThrow(/no gift card payment setting/i) + }) + + // `amount_cents` is immutable, so a session created against a larger + // remainder is not stale but wrong: left alone it would still read as the + // selection and authorize more than is owed. + it("deletes the session paying the difference", async () => { + const sdk = stubSdk() + + await applyGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([session({ id: "method", amount_cents: 7100 })]), + giftCardCode: "ABC", + }) + + expect(sdk.del).toHaveBeenCalledWith("method") + }) + + it("leaves an authorized method session alone", async () => { + const sdk = stubSdk() + + await applyGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([ + session({ id: "method", payment_authorization: { status: "succeeded" } as never }), + ]), + giftCardCode: "ABC", + }) + + expect(sdk.del).not.toHaveBeenCalled() + }) + + // The shopper asked for the gift card and can see whether it landed; turning + // a cleanup failure into a visible error would report the wrong thing. + it("still succeeds when the cleanup delete fails", async () => { + const sdk = stubSdk() + sdk.del.mockRejectedValue(new Error("Forbidden")) + + await expect( + applyGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([session({ id: "method" })]), + giftCardCode: "ABC", + }) + ).resolves.toMatchObject({ id: "gift-new" }) + }) +}) + +describe("removeGiftCard", () => { + it("deletes the gift card session", async () => { + const sdk = stubSdk() + + await removeGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([giftCard("gift-a", 2000)]), + paymentSessionId: "gift-a", + }) + + expect(sdk.del).toHaveBeenCalledWith("gift-a") + }) + + // Removing raises the remainder, so the method session's fixed amount is now + // too small — the same reason applying one invalidates it. + it("also deletes the session paying the difference", async () => { + const sdk = stubSdk() + + await removeGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([giftCard("gift-a", 2000), session({ id: "method", amount_cents: 5100 })]), + paymentSessionId: "gift-a", + }) + + expect(sdk.del.mock.calls.map((call) => call[0])).toEqual(["gift-a", "method"]) + }) + + // Authorizing a gift card debits the balance immediately, and only a refund + // could return it — which this iteration does not implement. The API would + // refuse the delete anyway, with an unhandled 500. + it("refuses to remove a gift card that has been charged", async () => { + const sdk = stubSdk() + + await expect( + removeGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([ + giftCard("gift-a", 2000, { payment_authorization: { status: "succeeded" } as never }), + ]), + paymentSessionId: "gift-a", + }) + ).rejects.toThrow(/already been charged/i) + expect(sdk.del).not.toHaveBeenCalled() + }) + + it("allows removing one whose authorization failed", async () => { + const sdk = stubSdk() + + await removeGiftCard({ + accessToken: ACCESS_TOKEN, + order: order([ + giftCard("gift-a", 2000, { payment_authorization: { status: "failed" } as never }), + ]), + paymentSessionId: "gift-a", + }) + + expect(sdk.del).toHaveBeenCalledWith("gift-a") + }) +}) diff --git a/packages/core-components/src/payment_sessions/index.ts b/packages/core-components/src/payment_sessions/index.ts index f123a59d..9b5bb6cb 100644 --- a/packages/core-components/src/payment_sessions/index.ts +++ b/packages/core-components/src/payment_sessions/index.ts @@ -1,8 +1,12 @@ +export { applyGiftCard } from "./applyGiftCard" export { createPaymentSession } from "./createPaymentSession" +export type { PaymentSessionsState } from "./derivePaymentSessionsState" +export { derivePaymentSessionsState } from "./derivePaymentSessionsState" export { findCurrentPaymentSession } from "./findCurrentPaymentSession" export { findReusablePaymentSession } from "./findReusablePaymentSession" export type { PaymentsModel } from "./getPaymentsModel" export { getPaymentsModel } from "./getPaymentsModel" +export { invalidateCurrentPaymentSession } from "./invalidateCurrentPaymentSession" export { mapPlaceabilityErrors } from "./mapPlaceabilityErrors" export type { PlaceOrderWithPaymentSessionsResult } from "./placeOrderWithPaymentSessions" export { @@ -10,6 +14,7 @@ export { DEFAULT_PLACEABLE_INTERVAL_MS, placeOrderWithPaymentSessions, } from "./placeOrderWithPaymentSessions" +export { removeGiftCard } from "./removeGiftCard" export type { KnownPaymentSessionStatus, KnownPaymentTransactionStatus, @@ -18,6 +23,9 @@ export type { PlaceabilityError, } from "./types" export { + GIFT_CARD_SETTING_TYPE, + hasLiveAuthorization, + isGiftCardSession, PAYMENT_TAKEN_SESSION_STATUSES, TERMINAL_FAILURE_TRANSACTION_STATUSES, } from "./types" diff --git a/packages/core-components/src/payment_sessions/invalidateCurrentPaymentSession.ts b/packages/core-components/src/payment_sessions/invalidateCurrentPaymentSession.ts new file mode 100644 index 00000000..55d51ef4 --- /dev/null +++ b/packages/core-components/src/payment_sessions/invalidateCurrentPaymentSession.ts @@ -0,0 +1,45 @@ +import type { Order } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" +import { findCurrentPaymentSession } from "./findCurrentPaymentSession" +import { hasLiveAuthorization } from "./types" + +interface InvalidateCurrentPaymentSessionParams + extends Pick { + order: Order +} + +/** + * Delete the session paying the difference, because the difference changed. + * + * `amount_cents` is set once and never updatable, so a session created against + * an older remainder is not merely stale — it is wrong. Left in place it would + * still read as the shopper's current selection (it is `unpaid` and unburnt), + * and at place time we would authorize an amount larger than what is owed. + * + * Only sessions that have taken nothing are deleted. One carrying a live + * authorization is left alone: the API refuses to delete a session with + * transactions attached — and surfaces that refusal as an unhandled 500 — and a + * sales-channel token cannot delete the authorization either. Money already + * taken is not ours to undo here. + * + * Failures are swallowed. This runs alongside a gift card operation the shopper + * asked for and can see the result of; turning a cleanup failure into a visible + * error would report the wrong thing. The stale session stays out of the + * selection anyway, because the amount check that follows it is server-side. + */ +export async function invalidateCurrentPaymentSession({ + accessToken, + interceptors, + order, +}: InvalidateCurrentPaymentSessionParams): Promise { + const current = findCurrentPaymentSession({ paymentSessions: order.payment_sessions }) + if (current == null || hasLiveAuthorization(current)) return + + const sdk = getSdk({ accessToken, interceptors }) + try { + await sdk.payment_sessions.delete(current.id) + } catch { + // Best effort — see above. + } +} diff --git a/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.spec.ts b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.spec.ts index cd937a9c..b6a124c5 100644 --- a/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.spec.ts +++ b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.spec.ts @@ -7,13 +7,36 @@ vi.mock("#sdk", () => ({ getSdk: getSdkMock })) const ORDER_ID = "order-1" const ACCESS_TOKEN = "token" +const MANUAL = { id: "ps-manual", type: "payment_setting_manuals" } +const GIFT_CARD = { id: "ps-gift", type: "payment_setting_gift_cards" } -function order(status: Order["status"]): Order { - return { id: ORDER_ID, type: "orders", status } as Order +function session(overrides: Partial = {}): PaymentSession { + return { + id: "session-1", + type: "payment_sessions", + status: "unpaid", + amount_cents: 7100, + payment_setting: MANUAL, + ...overrides, + } as PaymentSession +} + +function giftCard(id: string, overrides: Partial = {}): PaymentSession { + return session({ id, payment_setting: GIFT_CARD as never, amount_cents: 1000, ...overrides }) +} + +function order(sessions: PaymentSession[], status: Order["status"] = "pending"): Order { + return { + id: ORDER_ID, + type: "orders", + status, + total_amount_with_taxes_cents: 7100, + payment_sessions: sessions, + } as Order } /** A 422 from the `_placeable` trigger, as the API shapes it. */ -function placeabilityRefusal(detail = "The payment doesn't cover the order.") { +function refusal(detail = "The payment doesn't cover the order.") { return { errors: [ { @@ -26,15 +49,6 @@ function placeabilityRefusal(detail = "The payment doesn't cover the order.") { } } -function session(overrides: Partial = {}): PaymentSession { - return { - id: "session-1", - type: "payment_sessions", - status: "unpaid", - ...overrides, - } as PaymentSession -} - interface SdkStub { _placeable: ReturnType _place: ReturnType @@ -43,8 +57,8 @@ interface SdkStub { function stubSdk(): SdkStub { const stub: SdkStub = { - _placeable: vi.fn(), - _place: vi.fn().mockResolvedValue(order("placed")), + _placeable: vi.fn().mockResolvedValue(order([], "pending")), + _place: vi.fn().mockResolvedValue(order([], "placed")), createAuthorization: vi.fn().mockResolvedValue({ id: "auth-1" }), } getSdkMock.mockReturnValue({ @@ -55,10 +69,14 @@ function stubSdk(): SdkStub { return stub } -// intervalMs: 0 keeps the retry loop synchronous. The delay is the thing under -// test only in "waits between attempts", where it is asserted separately. +/** Keeps the retry loop synchronous. The delay is asserted separately. */ const NO_WAIT = { attempts: 5, intervalMs: 0 } +/** Session ids the authorizations were created for, in order. */ +function authorizedIds(sdk: SdkStub): string[] { + return sdk.createAuthorization.mock.calls.map((call) => call[0].payment_session.id) +} + beforeEach(() => { vi.clearAllMocks() }) @@ -66,33 +84,28 @@ beforeEach(() => { describe("placeOrderWithPaymentSessions", () => { it("authorizes, checks placeability, then places", async () => { const sdk = stubSdk() - sdk._placeable.mockResolvedValue(order("pending")) const result = await placeOrderWithPaymentSessions({ accessToken: ACCESS_TOKEN, - orderId: ORDER_ID, - paymentSession: session(), + order: order([session()]), ...NO_WAIT, }) - expect(sdk.createAuthorization).toHaveBeenCalledWith({ - payment_session: { id: "session-1", type: "payment_sessions" }, - }) + expect(authorizedIds(sdk)).toEqual(["session-1"]) expect(sdk._placeable).toHaveBeenCalledWith(ORDER_ID) expect(sdk._place).toHaveBeenCalledWith(ORDER_ID) expect(result).toMatchObject({ placed: true, errors: [], timedOut: false }) }) - // auto_place on the Payment Setting places the order inside the - // authorization job, so it can be placed before we ever look. + // auto_place places the order inside the authorization job, so it can be + // placed before we ever look. it("skips _place when the order was already placed by auto_place", async () => { const sdk = stubSdk() - sdk._placeable.mockResolvedValue(order("placed")) + sdk._placeable.mockResolvedValue(order([], "placed")) const result = await placeOrderWithPaymentSessions({ accessToken: ACCESS_TOKEN, - orderId: ORDER_ID, - paymentSession: session(), + order: order([session()]), ...NO_WAIT, }) @@ -100,19 +113,16 @@ describe("placeOrderWithPaymentSessions", () => { expect(result).toMatchObject({ placed: true, timedOut: false }) }) - // The whole point of the loop: authorizing is asynchronous, so an early - // refusal is not a real failure. it("retries a placeability refusal and succeeds once the authorization lands", async () => { const sdk = stubSdk() sdk._placeable - .mockRejectedValueOnce(placeabilityRefusal()) - .mockRejectedValueOnce(placeabilityRefusal()) - .mockResolvedValue(order("pending")) + .mockRejectedValueOnce(refusal()) + .mockRejectedValueOnce(refusal()) + .mockResolvedValue(order([], "pending")) const result = await placeOrderWithPaymentSessions({ accessToken: ACCESS_TOKEN, - orderId: ORDER_ID, - paymentSession: session(), + order: order([session()]), ...NO_WAIT, }) @@ -122,12 +132,11 @@ describe("placeOrderWithPaymentSessions", () => { it("reports the last refusal once the attempts run out", async () => { const sdk = stubSdk() - sdk._placeable.mockRejectedValue(placeabilityRefusal("Still not covered.")) + sdk._placeable.mockRejectedValue(refusal("Still not covered.")) const result = await placeOrderWithPaymentSessions({ accessToken: ACCESS_TOKEN, - orderId: ORDER_ID, - paymentSession: session(), + order: order([session()]), attempts: 3, intervalMs: 0, }) @@ -148,23 +157,20 @@ describe("placeOrderWithPaymentSessions", () => { it("waits between attempts", async () => { const sdk = stubSdk() - sdk._placeable.mockRejectedValue(placeabilityRefusal()) + sdk._placeable.mockRejectedValue(refusal()) const started = Date.now() await placeOrderWithPaymentSessions({ accessToken: ACCESS_TOKEN, - orderId: ORDER_ID, - paymentSession: session(), + order: order([session()]), attempts: 3, intervalMs: 20, }) - // Two gaps between three attempts, and no trailing wait after the last one. + // Two gaps between three attempts, and no trailing wait after the last. expect(Date.now() - started).toBeGreaterThanOrEqual(40) }) - // Waiting cannot fix a 401 or a network failure, so burning five attempts on - // it only delays the report. it("rethrows an error that is not a placeability refusal", async () => { const sdk = stubSdk() sdk._placeable.mockRejectedValue(new Error("Unauthorized")) @@ -172,41 +178,99 @@ describe("placeOrderWithPaymentSessions", () => { await expect( placeOrderWithPaymentSessions({ accessToken: ACCESS_TOKEN, - orderId: ORDER_ID, - paymentSession: session(), + order: order([session()]), ...NO_WAIT, }) ).rejects.toThrow("Unauthorized") expect(sdk._placeable).toHaveBeenCalledTimes(1) }) - describe("authorization", () => { - it("creates one when the session has none", async () => { + describe("gift cards", () => { + // Each authorization shrinks what the next session may take, and a gift + // card charged after a failed method payment leaves the shopper's balance + // spent on an order that never got placed. + it("authorizes every gift card before the session paying the difference", async () => { const sdk = stubSdk() - sdk._placeable.mockResolvedValue(order("pending")) await placeOrderWithPaymentSessions({ accessToken: ACCESS_TOKEN, - orderId: ORDER_ID, - paymentSession: session(), + order: order([ + session({ id: "method", amount_cents: 5100 }), + giftCard("gift-a", { created_at: "2026-08-20T10:00:00Z" }), + giftCard("gift-b", { created_at: "2026-08-20T11:00:00Z" }), + ]), ...NO_WAIT, }) - expect(sdk.createAuthorization).toHaveBeenCalledOnce() + expect(authorizedIds(sdk)).toEqual(["gift-a", "gift-b", "method"]) }) - // Creating a second authorization over one still in flight risks taking - // the money twice. + // Gift cards can cover the order outright, and then there is no other + // session at all. + it("places an order covered entirely by gift cards", async () => { + const sdk = stubSdk() + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([giftCard("gift-a", { amount_cents: 7100 })]), + ...NO_WAIT, + }) + + expect(authorizedIds(sdk)).toEqual(["gift-a"]) + expect(result.placed).toBe(true) + }) + + // Carrying on would charge more cards for an order that is not going to be + // placed. Nothing is rolled back: this iteration implements no refund. + it("stops at the first authorization failure and reports it", async () => { + const sdk = stubSdk() + sdk.createAuthorization.mockResolvedValueOnce({ id: "auth-a" }).mockRejectedValueOnce({ + errors: [{ code: "VALIDATION_ERROR", detail: "Gift card is empty." }], + }) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([ + session({ id: "method" }), + giftCard("gift-a", { created_at: "2026-08-20T10:00:00Z" }), + giftCard("gift-b", { created_at: "2026-08-20T11:00:00Z" }), + ]), + ...NO_WAIT, + }) + + expect(authorizedIds(sdk)).toEqual(["gift-a", "gift-b"]) + expect(sdk._placeable).not.toHaveBeenCalled() + expect(result).toMatchObject({ placed: false, timedOut: false }) + expect(result.errors[0]?.message).toBe("Gift card is empty.") + }) + + it("skips gift cards that are already authorized", async () => { + const sdk = stubSdk() + + await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([ + giftCard("gift-done", { payment_authorization: { status: "succeeded" } as never }), + giftCard("gift-todo"), + ]), + ...NO_WAIT, + }) + + expect(authorizedIds(sdk)).toEqual(["gift-todo"]) + }) + }) + + describe("authorization of the session paying the difference", () => { + // Creating a second authorization over one still in flight risks taking the + // money twice. it.each(["pending", "processing", "requires_action", "succeeded"])( "does not create a second one when the existing authorization is %s", async (status) => { const sdk = stubSdk() - sdk._placeable.mockResolvedValue(order("pending")) await placeOrderWithPaymentSessions({ accessToken: ACCESS_TOKEN, - orderId: ORDER_ID, - paymentSession: session({ payment_authorization: { status } as never }), + order: order([session({ payment_authorization: { status } as never })]), ...NO_WAIT, }) @@ -214,30 +278,30 @@ describe("placeOrderWithPaymentSessions", () => { } ) + // A burnt session is not the selection any more — the radio reads as + // unchecked and the shopper picks again, which creates a fresh session. So + // nothing is authorized here, and nothing is charged twice. it.each(["declined", "failed", "canceled", "expired"])( - "creates a new one when the existing authorization is %s", + "authorizes nothing when the only session's authorization is %s", async (status) => { const sdk = stubSdk() - sdk._placeable.mockResolvedValue(order("pending")) await placeOrderWithPaymentSessions({ accessToken: ACCESS_TOKEN, - orderId: ORDER_ID, - paymentSession: session({ payment_authorization: { status } as never }), + order: order([session({ payment_authorization: { status } as never })]), ...NO_WAIT, }) - expect(sdk.createAuthorization).toHaveBeenCalledOnce() + expect(sdk.createAuthorization).not.toHaveBeenCalled() } ) - it("skips authorization entirely for an order with no session", async () => { + it("authorizes nothing for an order with no sessions", async () => { const sdk = stubSdk() - sdk._placeable.mockResolvedValue(order("pending")) const result = await placeOrderWithPaymentSessions({ accessToken: ACCESS_TOKEN, - orderId: ORDER_ID, + order: order([]), ...NO_WAIT, }) diff --git a/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts index 9512fbcb..6630ca8d 100644 --- a/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts +++ b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts @@ -1,8 +1,9 @@ import type { Order, PaymentSession } from "@commercelayer/sdk" import { getSdk } from "#sdk" import type { RequestConfig } from "#types" +import { derivePaymentSessionsState } from "./derivePaymentSessionsState" import { mapPlaceabilityErrors } from "./mapPlaceabilityErrors" -import { type PlaceabilityError, TERMINAL_FAILURE_TRANSACTION_STATUSES } from "./types" +import { hasLiveAuthorization, type PlaceabilityError } from "./types" /** Attempts before the placeability check gives up. */ export const DEFAULT_PLACEABLE_ATTEMPTS = 5 @@ -11,13 +12,12 @@ export const DEFAULT_PLACEABLE_INTERVAL_MS = 1000 interface PlaceOrderWithPaymentSessionsParams extends Pick { - orderId: string /** - * The Current Payment Session to authorize. Omit for an order that needs no - * payment (a zero-total order), which goes straight to the placeability - * check. + * The order as last fetched, with `payment_sessions.payment_setting` and + * `payment_sessions.payment_authorization` included. Its sessions are what + * gets authorized, and in which order. */ - paymentSession?: PaymentSession | null + order: Order /** Placeability attempts before giving up. Defaults to 5. */ attempts?: number /** Delay between attempts, in milliseconds. Defaults to 1000. */ @@ -29,11 +29,11 @@ export interface PlaceOrderWithPaymentSessionsResult { placed: boolean /** The order as the API last returned it. */ order?: Order - /** Reasons the order could not be placed, from the final failed attempt. */ + /** Reasons the order could not be placed, or why an authorization failed. */ errors: PlaceabilityError[] /** - * True when the attempts ran out. The payment may still succeed later, so - * this must never be reported as a payment failure — see below. + * True when the placeability attempts ran out. The payment may still succeed + * later, so this must never be reported as a payment failure. */ timedOut: boolean } @@ -41,51 +41,79 @@ export interface PlaceOrderWithPaymentSessionsResult { /** * Take payment for an order on the `payment_sessions` model and place it. * - * The sequence is: create the Payment Authorization, poll `_placeable` until - * the API accepts, then `_place`. + * The sequence is: authorize the gift cards, then authorize the session paying + * the difference, then poll `_placeable`, then `_place`. * - * **Why the authorization is created here and not at selection.** An + * **Why gift cards first.** Nothing server-side enforces the order — it is a + * client-side safety property. Each authorization shrinks what the next session + * is allowed to take, and a gift card that is charged after a failed method + * payment would leave the shopper's balance spent on an order that never got + * placed. + * + * **Why the difference may be absent.** Gift cards can cover the order + * entirely, in which case there is no other session at all and this goes + * straight from the gift cards to the placeability check. + * + * **Why the authorizations are created here and not at selection.** An * authorization is the record proving money was taken. Creating it when the - * shopper picks a radio button would take their money on selection and make - * changing their mind a cascade delete of an accounting record. Creating it - * here keeps selection free and reversible. + * shopper picks a radio button, or types a gift card code, would take their + * money on selection and make changing their mind a refund. Creating it here + * keeps both reversible: a gift card can be removed for free right up to this + * point. * * **Why `_placeable` is retried instead of reported.** Authorizing is - * asynchronous — for a manual setting it runs in a background job — so the - * first check legitimately fails with "the payment doesn't cover the required - * percentage" while the money is still being taken. Reporting that straight - * away would tell the shopper their payment failed a second before it - * succeeded. Only an error that survives the last attempt is real. - * - * A failed `_placeable` persists nothing (the trigger is a validation, and a - * failed validation is not saved), so retrying it is cheap and side-effect - * free despite the PATCH verb. + * asynchronous — it runs in a background job — so the first check legitimately + * fails with "the payment doesn't cover the required percentage" while the + * money is still being taken. Reporting that straight away would tell the + * shopper their payment failed a second before it succeeded. Only an error that + * survives the last attempt is real. A failed `_placeable` persists nothing, so + * retrying is side-effect free despite the PATCH verb. * * **There is no client-side coverage gate.** Coverage is enforced by a payment * rule whose threshold an organization can change — it can be lowered to accept * part payments — so only the server knows what "covered" means for this order. + * + * **Nothing is ever rolled back.** If an authorization fails partway, the ones + * already taken stay taken: this iteration implements no refund, and the gift + * card list is itself the recovery surface — after a reload the shopper sees + * which cards were charged and what is left to pay. On a timeout nothing is + * touched at all, because the payment may well have succeeded. */ export async function placeOrderWithPaymentSessions({ accessToken, interceptors, - orderId, - paymentSession, + order, attempts = DEFAULT_PLACEABLE_ATTEMPTS, intervalMs = DEFAULT_PLACEABLE_INTERVAL_MS, }: PlaceOrderWithPaymentSessionsParams): Promise { const sdk = getSdk({ accessToken, interceptors }) + const state = derivePaymentSessionsState(order) + + // Gift cards first, then the difference. Sequential, not concurrent: each + // authorization changes what the next one is allowed to take. + const toAuthorize = [...state.giftCardSessions, state.currentPaymentSession] + .filter((session): session is PaymentSession => session != null) + .filter(needsAuthorization) - if (paymentSession != null && needsAuthorization(paymentSession)) { - await sdk.payment_authorizations.create({ - payment_session: sdk.payment_sessions.relationship(paymentSession.id), - }) + for (const session of toAuthorize) { + try { + await sdk.payment_authorizations.create({ + payment_session: sdk.payment_sessions.relationship(session.id), + }) + } catch (error) { + // Stop at the first failure. Carrying on would charge more cards for an + // order that is not going to be placed. + const errors = mapPlaceabilityErrors(error) + if (errors.length === 0) throw error + return { placed: false, errors, timedOut: false } + } } let lastErrors: PlaceabilityError[] = [] for (let attempt = 1; attempt <= attempts; attempt++) { try { - const checked = await sdk.orders._placeable(orderId) + const checked = await sdk.orders._placeable(order.id) // `auto_place` on the Payment Setting places the order inside the // authorization job, so it can already be placed by the time we look. @@ -96,7 +124,7 @@ export async function placeOrderWithPaymentSessions({ return { placed: true, order: checked, errors: [], timedOut: false } } - const placed = await sdk.orders._place(orderId) + const placed = await sdk.orders._place(order.id) return { placed: placed.status === "placed", order: placed, errors: [], timedOut: false } } catch (error) { lastErrors = mapPlaceabilityErrors(error) @@ -111,17 +139,18 @@ export async function placeOrderWithPaymentSessions({ } /** - * Skip the authorization when the session already has one that is still alive. - * Only a terminal failure means the shopper is genuinely retrying; a `pending` - * or `processing` authorization is still in flight, and creating a second one - * risks taking the money twice. + * Skip the authorization when the session already has one that has not failed. + * A `pending` or `processing` authorization is still in flight, and creating a + * second one risks taking the money twice. + * + * Sessions whose authorization *did* fail never reach here: a burnt session is + * excluded from both the current selection and the gift card list, so the + * shopper re-selects and gets a fresh one. This stays as a guard rather than + * because the case is expected — double-charging is the failure mode it + * prevents, and that is worth a redundant check. */ function needsAuthorization(paymentSession: PaymentSession): boolean { - const status = paymentSession.payment_authorization?.status - if (status == null) return true - return TERMINAL_FAILURE_TRANSACTION_STATUSES.includes( - status as (typeof TERMINAL_FAILURE_TRANSACTION_STATUSES)[number] - ) + return !hasLiveAuthorization(paymentSession) } async function sleep(ms: number): Promise { diff --git a/packages/core-components/src/payment_sessions/removeGiftCard.ts b/packages/core-components/src/payment_sessions/removeGiftCard.ts new file mode 100644 index 00000000..17efaf7d --- /dev/null +++ b/packages/core-components/src/payment_sessions/removeGiftCard.ts @@ -0,0 +1,50 @@ +import type { Order } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" +import { invalidateCurrentPaymentSession } from "./invalidateCurrentPaymentSession" +import { hasLiveAuthorization } from "./types" + +interface RemoveGiftCardParams extends Pick { + order: Order + /** Id of the gift card Payment Session to remove. */ + paymentSessionId: string +} + +/** + * Take a gift card back off an order by deleting its Payment Session. + * + * Only possible while the card has not been charged. Authorizing a gift card + * debits the balance immediately — the setting forces auto-capture, so the + * session goes straight to `paid` — and from there only a refund could return + * it, which this iteration does not implement. The API would refuse the delete + * anyway, with an unhandled 500, and a sales-channel token cannot delete the + * authorization to clear the way. + * + * Deleting an unauthorized session touches no balance: nothing was taken, so + * there is nothing to give back. + * + * Removing a gift card raises the remainder, which **invalidates the session + * paying the difference** for the same reason applying one does — its amount is + * immutable and now too small. + */ +export async function removeGiftCard({ + accessToken, + interceptors, + order, + paymentSessionId, +}: RemoveGiftCardParams): Promise { + const session = (order.payment_sessions ?? []).find( + (candidate) => candidate.id === paymentSessionId + ) + + if (session != null && hasLiveAuthorization(session)) { + throw new Error( + "This gift card has already been charged and can no longer be removed from the order." + ) + } + + const sdk = getSdk({ accessToken, interceptors }) + await sdk.payment_sessions.delete(paymentSessionId) + + await invalidateCurrentPaymentSession({ accessToken, interceptors, order }) +} diff --git a/packages/core-components/src/payment_sessions/types.ts b/packages/core-components/src/payment_sessions/types.ts index 1be67208..e1418376 100644 --- a/packages/core-components/src/payment_sessions/types.ts +++ b/packages/core-components/src/payment_sessions/types.ts @@ -1,3 +1,5 @@ +import type { PaymentSession } from "@commercelayer/sdk" + /** * Status values for the `payment_sessions` payment model. * @@ -89,3 +91,33 @@ export interface PlaceabilityError { /** Symbolic reason from `meta.error`, when the API sends one. */ meta?: { error: string } } + +/** + * The Payment Setting type a gift card is spent through. + * + * Gift cards are the one setting that is *additive* rather than an alternative: + * an order carries zero or more of them plus at most one other session for the + * difference. Everything that separates the two families keys off this literal. + */ +export const GIFT_CARD_SETTING_TYPE = "payment_setting_gift_cards" + +/** True when this session spends a gift card rather than paying the difference. */ +export function isGiftCardSession(session: PaymentSession): boolean { + return session.payment_setting?.type === GIFT_CARD_SETTING_TYPE +} + +/** + * True when the session is carrying a Payment Authorization that has not failed + * — i.e. money is either taken or in flight. + * + * Note this is *not* "paid": an authorization can still be `pending` while its + * background job runs. What it rules out is a session the shopper may still + * change freely. + */ +export function hasLiveAuthorization(session: PaymentSession): boolean { + const status = session.payment_authorization?.status + if (status == null) return false + return !TERMINAL_FAILURE_TRANSACTION_STATUSES.includes( + status as (typeof TERMINAL_FAILURE_TRANSACTION_STATUSES)[number] + ) +} diff --git a/packages/react-components/specs/orders/place-order-payments-model.spec.tsx b/packages/react-components/specs/orders/place-order-payments-model.spec.tsx index 8dc20ffd..c55dff21 100644 --- a/packages/react-components/specs/orders/place-order-payments-model.spec.tsx +++ b/packages/react-components/specs/orders/place-order-payments-model.spec.tsx @@ -134,7 +134,9 @@ describe("PlaceOrderButtonPaymentSessions", () => { expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(false) }) - it("places the order with the current session", async () => { + // The whole order is handed over: which sessions get authorized, and in which + // order, is decided by the sequence rather than here. + it("hands the order to the place-order sequence", async () => { const onClick = vi.fn() renderButton(orderOnSessions(), { onClick }) @@ -144,10 +146,7 @@ describe("PlaceOrderButtonPaymentSessions", () => { await waitFor(() => { expect(placeOrderMock).toHaveBeenCalledWith( - expect.objectContaining({ - orderId: "order-1", - paymentSession: expect.objectContaining({ id: "session-1" }), - }) + expect.objectContaining({ order: expect.objectContaining({ id: "order-1" }) }) ) }) expect(onClick).toHaveBeenCalledWith(expect.objectContaining({ placed: true })) diff --git a/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx index 9117e511..c94ea6c6 100644 --- a/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx +++ b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx @@ -1,7 +1,6 @@ import { DEFAULT_PLACEABLE_ATTEMPTS, DEFAULT_PLACEABLE_INTERVAL_MS, - findCurrentPaymentSession, placeOrderWithPaymentSessions, } from "@commercelayer/core-components" import type { Order } from "@commercelayer/sdk" @@ -98,18 +97,13 @@ export function PlaceOrderButtonPaymentSessions(props: Props): JSX.Element { setIsLoading(true) setOrderErrors([]) try { - // The Current Payment Session is searched for, never read positionally: - // abandoned attempts leave inert sessions behind, and switching setting - // leaves the previous one in the same array. - const paymentSession = findCurrentPaymentSession({ - paymentSessions: order.payment_sessions, - }) - + // The whole order goes in: which sessions get authorized, and in which + // order — gift cards first, then the one paying the difference — is + // domain knowledge that belongs with the sequence, not here. const result = await placeOrderWithPaymentSessions({ accessToken, interceptors, - orderId: order.id, - paymentSession, + order, attempts: placeableAttempts, intervalMs: placeableIntervalMs, }) From 5b695447175dde26b617cd79fb43ca98faaa93ad Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:46:36 +0200 Subject: [PATCH 14/21] feat: gift card components for the payment_sessions model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second slice: the React surface over the domain operations. usePaymentSessionsState binds derivePaymentSessionsState, and is the one source for the applied cards, what is still owed, and whether anything is left to pay — read by the gift card components, the method selector, and . Seven PaymentSettingGiftCard* components, mounted outside : gift cards are additive rather than one of the alternatives a radio group picks between, so several can be active at once. The input and its submit appear and disappear together, swap for an "add another" control after the first card, and all three vanish once the order is covered or anything has been authorized — applying a card that is not needed fails with a 422 about amount_cents that a shopper cannot act on. Gift card errors live on their own context, apart from the method's: the two families have separate UIs and a failure in one must never surface under the other. That closes the shared-error debt that was due with this setting. renders nothing once nothing is left to pay, and takes a readonly prop that shows only what was chosen — the same recap gives the older model, which mfe-checkout was having to hand-roll. deducts the gift cards on this model. On the older one the same gift card was a negative line item and the total already came back net, so showing the gross would tell a shopper to pay money already covered. sums the sessions, since gift_card_amount_cents stays zero. A spec caught a real hazard on the way: isCovered read as true whenever the total was 0, so an order fetched without total_amount_with_taxes_cents in its fields hid the entire payment step. It now requires a total to exist before reporting coverage. Co-Authored-By: Claude Opus 5 (1M context) --- .../derivePaymentSessionsState.spec.ts | 15 +- .../derivePaymentSessionsState.ts | 16 +- .../PaymentSettingGiftCard.spec.tsx | 327 ++++++++++++++++++ .../src/components/orders/GiftCardAmount.tsx | 21 ++ .../src/components/orders/TotalAmount.tsx | 30 ++ .../payment_settings/PaymentSetting.tsx | 64 ++-- .../PaymentSettingGiftCard.tsx | 130 +++++++ .../PaymentSettingGiftCardAddButton.tsx | 44 +++ .../PaymentSettingGiftCardInput.tsx | 57 +++ .../PaymentSettingGiftCardList.tsx | 52 +++ .../PaymentSettingGiftCardListItem.tsx | 59 ++++ .../PaymentSettingGiftCardRemoveButton.tsx | 59 ++++ .../PaymentSettingGiftCardSubmitButton.tsx | 56 +++ .../PaymentSettingRadioButton.tsx | 8 +- .../context/PaymentSettingChildrenContext.ts | 2 + .../context/PaymentSettingGiftCardContext.ts | 55 +++ .../PaymentSettingGiftCardItemContext.ts | 33 ++ .../src/hooks/usePaymentSessionsState.ts | 28 ++ packages/react-components/src/index.ts | 8 + 19 files changed, 1037 insertions(+), 27 deletions(-) create mode 100644 packages/react-components/specs/payment_settings/PaymentSettingGiftCard.spec.tsx create mode 100644 packages/react-components/src/components/payment_settings/PaymentSettingGiftCard.tsx create mode 100644 packages/react-components/src/components/payment_settings/PaymentSettingGiftCardAddButton.tsx create mode 100644 packages/react-components/src/components/payment_settings/PaymentSettingGiftCardInput.tsx create mode 100644 packages/react-components/src/components/payment_settings/PaymentSettingGiftCardList.tsx create mode 100644 packages/react-components/src/components/payment_settings/PaymentSettingGiftCardListItem.tsx create mode 100644 packages/react-components/src/components/payment_settings/PaymentSettingGiftCardRemoveButton.tsx create mode 100644 packages/react-components/src/components/payment_settings/PaymentSettingGiftCardSubmitButton.tsx create mode 100644 packages/react-components/src/context/PaymentSettingGiftCardContext.ts create mode 100644 packages/react-components/src/context/PaymentSettingGiftCardItemContext.ts create mode 100644 packages/react-components/src/hooks/usePaymentSessionsState.ts diff --git a/packages/core-components/src/payment_sessions/derivePaymentSessionsState.spec.ts b/packages/core-components/src/payment_sessions/derivePaymentSessionsState.spec.ts index feb0f3f7..3b2ced70 100644 --- a/packages/core-components/src/payment_sessions/derivePaymentSessionsState.spec.ts +++ b/packages/core-components/src/payment_sessions/derivePaymentSessionsState.spec.ts @@ -49,10 +49,21 @@ describe("derivePaymentSessionsState", () => { expect(state.currentPaymentSession).toBeUndefined() }) - it("handles a missing order", () => { + // A total we do not have must never read as "nothing left to pay": an order + // fetched without `total_amount_with_taxes_cents` would otherwise hide the + // entire payment step. + it("does not report coverage for a missing order", () => { const state = derivePaymentSessionsState(undefined) expect(state.remainingAmountCents).toBe(0) - expect(state.isCovered).toBe(true) + expect(state.isCovered).toBe(false) + }) + + it("does not report coverage when the total is absent from the fetch", () => { + const state = derivePaymentSessionsState({ + id: "order-1", + payment_sessions: [], + } as never) + expect(state.isCovered).toBe(false) }) // The point of this derivation: the server's own remainder does not move diff --git a/packages/core-components/src/payment_sessions/derivePaymentSessionsState.ts b/packages/core-components/src/payment_sessions/derivePaymentSessionsState.ts index 4faa4eff..1b5f6b76 100644 --- a/packages/core-components/src/payment_sessions/derivePaymentSessionsState.ts +++ b/packages/core-components/src/payment_sessions/derivePaymentSessionsState.ts @@ -23,7 +23,12 @@ export interface PaymentSessionsState { * reimplementation of its rules. */ remainingAmountCents: number - /** True when nothing is left to pay, so no other payment method is needed. */ + /** + * True when the order had something to pay and it is now all covered. + * + * False when the total is unknown or zero: an order fetched without + * `total_amount_with_taxes_cents` must not read as paid for. + */ isCovered: boolean /** * Whether another gift card may be applied. @@ -67,11 +72,18 @@ export function derivePaymentSessionsState(order?: Order | null): PaymentSession const remainingAmountCents = Math.max(0, total - giftCardAmountCents - takenMethodAmountCents) + // `total > 0` guards against reading "nothing left to pay" out of a total we + // do not have. An order fetched without `total_amount_with_taxes_cents` in + // its `fields` looks free, and a bare `remainingAmountCents === 0` would then + // hide the whole payment step. A genuinely free order needs no payment either, + // but that is decided from the total itself, not from coverage. + const isCovered = total > 0 && remainingAmountCents === 0 + return { giftCardSessions, giftCardAmountCents, remainingAmountCents, - isCovered: remainingAmountCents === 0, + isCovered, canAddGiftCard: remainingAmountCents > 0 && !sessions.some(hasLiveAuthorization), currentPaymentSession: findCurrentPaymentSession({ paymentSessions: sessions }), giftCardSettingId: (order?.available_payment_settings ?? []).find( diff --git a/packages/react-components/specs/payment_settings/PaymentSettingGiftCard.spec.tsx b/packages/react-components/specs/payment_settings/PaymentSettingGiftCard.spec.tsx new file mode 100644 index 00000000..5825e55c --- /dev/null +++ b/packages/react-components/specs/payment_settings/PaymentSettingGiftCard.spec.tsx @@ -0,0 +1,327 @@ +import type { Order } from "@commercelayer/sdk" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import type { ReactNode } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { PaymentSetting } from "#components/payment_settings/PaymentSetting" +import { PaymentSettingGiftCard } from "#components/payment_settings/PaymentSettingGiftCard" +import { PaymentSettingGiftCardAddButton } from "#components/payment_settings/PaymentSettingGiftCardAddButton" +import { PaymentSettingGiftCardInput } from "#components/payment_settings/PaymentSettingGiftCardInput" +import { PaymentSettingGiftCardList } from "#components/payment_settings/PaymentSettingGiftCardList" +import { PaymentSettingGiftCardListItem } from "#components/payment_settings/PaymentSettingGiftCardListItem" +import { PaymentSettingGiftCardRemoveButton } from "#components/payment_settings/PaymentSettingGiftCardRemoveButton" +import { PaymentSettingGiftCardSubmitButton } from "#components/payment_settings/PaymentSettingGiftCardSubmitButton" +import { PaymentSettingName } from "#components/payment_settings/PaymentSettingName" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" + +const { applyGiftCardMock, removeGiftCardMock } = vi.hoisted(() => ({ + applyGiftCardMock: vi.fn(), + removeGiftCardMock: vi.fn(), +})) + +vi.mock("@commercelayer/core-components", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, applyGiftCard: applyGiftCardMock, removeGiftCard: removeGiftCardMock } +}) + +const MANUAL = { id: "ps-manual", type: "payment_setting_manuals", name: "Bank transfer" } +const GIFT_CARD = { id: "ps-gift", type: "payment_setting_gift_cards", name: "Gift card" } +const TOTAL = 7100 + +function giftCardSession(id: string, amountCents: number, overrides = {}) { + return { + id, + status: "unpaid", + amount_cents: amountCents, + formatted_amount: `$${(amountCents / 100).toFixed(2)}`, + gift_card_code: `CODE-${id}`, + payment_setting: GIFT_CARD, + ...overrides, + } +} + +function order(overrides: Partial = {}): Partial { + return { + id: "order-1", + currency_code: "USD", + total_amount_with_taxes_cents: TOTAL, + available_payment_settings: [MANUAL, GIFT_CARD], + payment_sessions: [], + ...overrides, + } as Partial +} + +const getOrder = vi.fn() + +function Wrapper({ + children, + currentOrder, +}: { + children: ReactNode + currentOrder?: Partial | null +}) { + return ( + + + {children} + + + ) +} + +function renderGiftCards(currentOrder: Partial | null = order(), readonly = false) { + return render( + + + + + + + + + + + + ) +} + +beforeEach(() => { + vi.clearAllMocks() + applyGiftCardMock.mockResolvedValue({ id: "gift-new" }) + removeGiftCardMock.mockResolvedValue(undefined) +}) + +describe("PaymentSettingGiftCard", () => { + it("renders nothing on the payment_source model", () => { + renderGiftCards( + order({ + available_payment_settings: [], + available_payment_methods: [{ id: "pm-1" }], + } as never) + ) + expect(screen.queryByTestId("input")).toBeNull() + }) + + it("renders nothing when the order has no gift card setting", () => { + renderGiftCards(order({ available_payment_settings: [MANUAL] } as never)) + expect(screen.queryByTestId("input")).toBeNull() + }) + + it("shows the input straight away when nothing is applied", () => { + renderGiftCards() + expect(screen.getByTestId("input")).toBeTruthy() + expect(screen.queryByTestId("add")).toBeNull() + }) + + it("applies a typed code", async () => { + renderGiftCards() + + await act(async () => { + fireEvent.change(screen.getByTestId("input"), { target: { value: " ABC " } }) + }) + await act(async () => { + fireEvent.click(screen.getByTestId("apply")) + }) + + await waitFor(() => { + expect(applyGiftCardMock).toHaveBeenCalledWith( + expect.objectContaining({ giftCardCode: "ABC" }) + ) + }) + expect(getOrder).toHaveBeenCalledWith("order-1") + }) + + it("keeps apply disabled until something is typed", () => { + renderGiftCards() + expect((screen.getByTestId("apply") as HTMLButtonElement).disabled).toBe(true) + }) + + // Selection errors live on this component's own context, kept apart from the + // method's: a gift card failure must never surface under the payment method. + it("reports a failed code without touching the order", async () => { + applyGiftCardMock.mockRejectedValue(new Error("doesn't match any active gift card")) + render( + + + + + + + ) + + await act(async () => { + fireEvent.change(screen.getByTestId("input"), { target: { value: "NOPE" } }) + }) + await act(async () => { + fireEvent.click(screen.getByTestId("apply")) + }) + + await waitFor(() => { + expect(applyGiftCardMock).toHaveBeenCalled() + }) + expect(getOrder).not.toHaveBeenCalled() + }) + + describe("the applied list", () => { + it("renders one row per applied card, with code and amount", () => { + renderGiftCards( + order({ + payment_sessions: [giftCardSession("gift-a", 2000), giftCardSession("gift-b", 1500)], + } as never) + ) + const rows = screen.getAllByTestId("row") + expect(rows).toHaveLength(2) + expect(rows[0]?.textContent).toContain("CODE-gift-a") + expect(rows[0]?.textContent).toContain("$20.00") + }) + + // A burnt card took no money; listing it would say a payment is in place. + it("leaves out a card whose authorization failed", () => { + renderGiftCards( + order({ + payment_sessions: [ + giftCardSession("gift-a", 2000, { payment_authorization: { status: "failed" } }), + ], + } as never) + ) + expect(screen.queryByTestId("row")).toBeNull() + }) + + it("removes a card", async () => { + renderGiftCards(order({ payment_sessions: [giftCardSession("gift-a", 2000)] } as never)) + + await act(async () => { + fireEvent.click(screen.getByTestId("remove")) + }) + + await waitFor(() => { + expect(removeGiftCardMock).toHaveBeenCalledWith( + expect.objectContaining({ paymentSessionId: "gift-a" }) + ) + }) + }) + + // Authorizing debits the balance immediately and only a refund would give + // it back, which this iteration does not implement. + it("hides remove once the card has been charged", () => { + renderGiftCards( + order({ + payment_sessions: [ + giftCardSession("gift-a", 2000, { payment_authorization: { status: "succeeded" } }), + ], + } as never) + ) + expect(screen.getByTestId("row")).toBeTruthy() + expect(screen.queryByTestId("remove")).toBeNull() + }) + }) + + describe("adding another", () => { + it("swaps the input for the add control after the first card", () => { + renderGiftCards(order({ payment_sessions: [giftCardSession("gift-a", 2000)] } as never)) + expect(screen.queryByTestId("input")).toBeNull() + expect(screen.getByTestId("add")).toBeTruthy() + }) + + it("brings the input back when asked", async () => { + renderGiftCards(order({ payment_sessions: [giftCardSession("gift-a", 2000)] } as never)) + + await act(async () => { + fireEvent.click(screen.getByTestId("add")) + }) + + expect(screen.getByTestId("input")).toBeTruthy() + expect(screen.queryByTestId("add")).toBeNull() + }) + + // Applying a card that is not needed fails with a 422 about amount_cents, + // which a shopper cannot act on. + it("offers neither once the gift cards cover the order", () => { + renderGiftCards(order({ payment_sessions: [giftCardSession("gift-a", TOTAL)] } as never)) + expect(screen.queryByTestId("input")).toBeNull() + expect(screen.queryByTestId("add")).toBeNull() + expect(screen.getByTestId("row")).toBeTruthy() + }) + + // Settling a partially-paid order is a flow this iteration does not + // implement, so nothing more is accepted once money is taken or in flight. + it("offers neither once anything has been authorized", () => { + renderGiftCards( + order({ + payment_sessions: [ + giftCardSession("gift-a", 1000, { payment_authorization: { status: "pending" } }), + ], + } as never) + ) + expect(screen.queryByTestId("input")).toBeNull() + expect(screen.queryByTestId("add")).toBeNull() + }) + }) + + describe("readonly", () => { + it("shows the applied cards without any controls", () => { + renderGiftCards(order({ payment_sessions: [giftCardSession("gift-a", 2000)] } as never), true) + expect(screen.getByTestId("row")).toBeTruthy() + expect(screen.queryByTestId("remove")).toBeNull() + expect(screen.queryByTestId("input")).toBeNull() + expect(screen.queryByTestId("add")).toBeNull() + }) + }) +}) + +describe("PaymentSetting alongside gift cards", () => { + function renderBoth(currentOrder: Partial, readonly = false) { + return render( + + + + + + ) + } + + it("offers the payment method while something is still owed", () => { + renderBoth(order({ payment_sessions: [giftCardSession("gift-a", 2000)] } as never)) + expect(screen.getByTestId("setting-name").textContent).toBe("Bank transfer") + }) + + // Nothing left to pay means nothing to choose. + it("offers no payment method once the gift cards cover the order", () => { + renderBoth(order({ payment_sessions: [giftCardSession("gift-a", TOTAL)] } as never)) + expect(screen.queryByTestId("setting-name")).toBeNull() + }) + + // A placed order is covered by definition; hiding what was used defeats the + // purpose of a recap. + it("still shows the chosen method in readonly, even when covered", () => { + renderBoth( + order({ + payment_sessions: [ + giftCardSession("gift-a", 5100), + { + id: "method", + status: "unpaid", + amount_cents: 2000, + payment_setting: MANUAL, + }, + ], + } as never), + true + ) + expect(screen.getByTestId("setting-name").textContent).toBe("Bank transfer") + }) +}) diff --git a/packages/react-components/src/components/orders/GiftCardAmount.tsx b/packages/react-components/src/components/orders/GiftCardAmount.tsx index 8e472af0..71817d9b 100644 --- a/packages/react-components/src/components/orders/GiftCardAmount.tsx +++ b/packages/react-components/src/components/orders/GiftCardAmount.tsx @@ -1,12 +1,33 @@ import { type JSX, useContext } from "react" import Parent from "#components/utils/Parent" import OrderContext from "#context/OrderContext" +import { usePaymentSessionsState } from "#hooks/usePaymentSessionsState" +import { usePaymentsModel } from "#hooks/usePaymentsModel" import type { BaseAmountComponent } from "#typings" import { manageGiftCard } from "#utils/adyen/manageGiftCard" +import { type CurrencyCode, formatCentsToCurrency } from "#utils/currencies" import BaseOrderPrice from "../utils/BaseOrderPrice" export function GiftCardAmount(props: BaseAmountComponent): JSX.Element | null { const { managePaymentProviderGiftCards, order } = useContext(OrderContext) + const paymentsModel = usePaymentsModel() + const { giftCardAmountCents } = usePaymentSessionsState() + + // The gift cards on this model are Payment Sessions, not a negative line + // item, so `order.gift_card_amount_cents` stays at zero and this has to sum + // the sessions instead. Shown as a deduction to match the older model, where + // the same money did reduce the order total. + if (paymentsModel === "payment_sessions") { + if (giftCardAmountCents === 0) return null + const price = `-${formatCentsToCurrency(giftCardAmountCents, order?.currency_code as CurrencyCode)}` + const parentProps = { price, priceCents: -giftCardAmountCents, ...props } + return props.children ? ( + {props.children} + ) : ( + {price} + ) + } + if (managePaymentProviderGiftCards) { const giftCardData = manageGiftCard({ order }) if (!giftCardData) return null diff --git a/packages/react-components/src/components/orders/TotalAmount.tsx b/packages/react-components/src/components/orders/TotalAmount.tsx index 17b263f5..36a86766 100644 --- a/packages/react-components/src/components/orders/TotalAmount.tsx +++ b/packages/react-components/src/components/orders/TotalAmount.tsx @@ -1,12 +1,42 @@ import { type JSX, useContext } from "react" import Parent from "#components/utils/Parent" import OrderContext from "#context/OrderContext" +import { usePaymentSessionsState } from "#hooks/usePaymentSessionsState" +import { usePaymentsModel } from "#hooks/usePaymentsModel" import type { BaseAmountComponent } from "#typings" import { manageGiftCard } from "#utils/adyen/manageGiftCard" +import { type CurrencyCode, formatCentsToCurrency } from "#utils/currencies" import BaseOrderPrice from "../utils/BaseOrderPrice" export function TotalAmount(props: BaseAmountComponent): JSX.Element | null { const { managePaymentProviderGiftCards, order } = useContext(OrderContext) + const paymentsModel = usePaymentsModel() + const { giftCardAmountCents } = usePaymentSessionsState() + + // On the `payment_sessions` model a gift card is a payment, not a discount, so + // `total_amount_with_taxes_cents` keeps the gross figure. On the older model + // the same gift card was a negative line item and the total already came back + // net — so showing the gross here would be a regression for anyone migrating, + // and would tell the shopper to pay money a gift card has already covered. + // + // Deducting here rather than reading `order.session_amount_cents`: that field + // does not move until a session is authorized, and gift cards are authorized + // at place time. + if (paymentsModel === "payment_sessions" && giftCardAmountCents > 0) { + // Net of the gift cards only, not of everything already authorized: on the + // older model an authorized payment source never reduced the total shown, + // and only the gift card did. This keeps the two models saying the same + // thing. + const netCents = Math.max(0, (order?.total_amount_with_taxes_cents ?? 0) - giftCardAmountCents) + const price = formatCentsToCurrency(netCents, order?.currency_code as CurrencyCode) + const parentProps = { price, priceCents: netCents, ...props } + return props.children ? ( + {props.children} + ) : ( + {price} + ) + } + if (managePaymentProviderGiftCards) { const giftCardData = manageGiftCard({ order }) if (!giftCardData) return diff --git a/packages/react-components/src/components/payment_settings/PaymentSetting.tsx b/packages/react-components/src/components/payment_settings/PaymentSetting.tsx index d82b5544..19c9c591 100644 --- a/packages/react-components/src/components/payment_settings/PaymentSetting.tsx +++ b/packages/react-components/src/components/payment_settings/PaymentSetting.tsx @@ -12,6 +12,7 @@ import { type JSX, type ReactNode, useContext, useEffect, useState } from "react import CommerceLayerContext from "#context/CommerceLayerContext" import OrderContext from "#context/OrderContext" import PaymentSettingChildrenContext from "#context/PaymentSettingChildrenContext" +import { usePaymentSessionsState } from "#hooks/usePaymentSessionsState" import { usePaymentsModel } from "#hooks/usePaymentsModel" import type { BaseError } from "#typings/errors" @@ -37,6 +38,12 @@ export interface PaymentSettingOnSelectParams { interface Props { children?: ReactNode + /** + * Show what was chosen without letting it change — a placed order, for + * instance. Renders only the selected setting, and keeps rendering it even + * once nothing is left to pay. + */ + readonly?: boolean /** * Fired once the selection has been stored and the order refetched — not on * click. The counterpart of ``'s `onClick`. @@ -55,8 +62,9 @@ interface Props { * can be mounted alongside `` without a coordinator above: each * tree silently steps aside when the order is not its own. */ -export function PaymentSetting({ children, onSelect }: Props): JSX.Element | null { +export function PaymentSetting({ children, onSelect, readonly }: Props): JSX.Element | null { const paymentsModel = usePaymentsModel() + const { isCovered } = usePaymentSessionsState() const { order, include, includeLoaded, addResourceToInclude, getOrder } = useContext(OrderContext) const { accessToken, interceptors } = useContext(CommerceLayerContext) const [pendingSettingId, setPendingSettingId] = useState(null) @@ -85,6 +93,15 @@ export function PaymentSetting({ children, onSelect }: Props): JSX.Element | nul if (paymentsModel !== "payment_sessions" || order == null) return null + // Nothing left to pay means nothing to choose. Gift cards can cover an order + // outright, and offering a payment method then is not merely redundant: + // creating that session fails with a 422 about `amount_cents` having to be + // greater than zero, which is not something a shopper can act on. + // + // Readonly is exempt — a placed order is covered by definition, and hiding + // what was used is the opposite of the point. + if (readonly !== true && isCovered) return null + const settings = (order.available_payment_settings ?? []).filter((setting) => { const implemented = IMPLEMENTED_SETTING_TYPES.includes( setting.type as (typeof IMPLEMENTED_SETTING_TYPES)[number] @@ -152,27 +169,32 @@ export function PaymentSetting({ children, onSelect }: Props): JSX.Element | nul return ( <> - {settings.map((setting) => { - const isSelected = selectedSession?.payment_setting?.id === setting.id - const currentPaymentSession = isSelected ? selectedSession : undefined - return ( - { - await selectSetting(setting) - }, - }} - > - {children} - + {settings + .filter( + (setting) => readonly !== true || selectedSession?.payment_setting?.id === setting.id ) - })} + .map((setting) => { + const isSelected = selectedSession?.payment_setting?.id === setting.id + const currentPaymentSession = isSelected ? selectedSession : undefined + return ( + { + await selectSetting(setting) + }, + }} + > + {children} + + ) + })} ) } diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCard.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCard.tsx new file mode 100644 index 00000000..2b423594 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCard.tsx @@ -0,0 +1,130 @@ +import { applyGiftCard, removeGiftCard } from "@commercelayer/core-components" +import { type JSX, type ReactNode, useCallback, useContext, useState } from "react" +import CommerceLayerContext from "#context/CommerceLayerContext" +import OrderContext from "#context/OrderContext" +import PaymentSettingGiftCardContext from "#context/PaymentSettingGiftCardContext" +import { usePaymentSessionsState } from "#hooks/usePaymentSessionsState" +import { usePaymentsModel } from "#hooks/usePaymentsModel" +import type { BaseError } from "#typings/errors" + +interface Props { + children?: ReactNode + /** + * Show what was applied without letting anything change — a placed order, for + * instance. Hides the input and the remove controls. + */ + readonly?: boolean +} + +/** + * Gift cards on the `payment_sessions` model. + * + * A gift card is a **payment**, not a discount: spending one creates a Payment + * Session, the order total never changes, and what drops is the amount still + * owed. Zero or more may be applied, and whatever is left is paid by one other + * Payment Setting. + * + * Deliberately **outside** ``. Gift cards are additive rather + * than one of the alternatives the shopper picks between, so they do not belong + * in a radio group: several can be active at once, and putting them there would + * mean more than one selection in a group with room for exactly one. + * + * Renders nothing unless the order is on the `payment_sessions` model and has a + * gift card Payment Setting available, so it can be mounted unconditionally. + * + * Applying and removing are single domain operations that also delete the + * session paying the difference — its amount is fixed at creation, so once the + * remainder moves that session is not stale but wrong. + */ +export function PaymentSettingGiftCard({ children, readonly }: Props): JSX.Element | null { + const paymentsModel = usePaymentsModel() + const state = usePaymentSessionsState() + const { order, getOrder } = useContext(OrderContext) + const { accessToken, interceptors } = useContext(CommerceLayerContext) + const [errors, setErrors] = useState([]) + const [isApplying, setIsApplying] = useState(false) + // The shopper asks for the input again after the first card. It starts open + // when nothing has been applied yet, which is the common case. + const [inputRequested, setInputRequested] = useState(false) + const [code, setCode] = useState("") + + const showInput = useCallback(() => { + setInputRequested(true) + }, []) + + const apply = useCallback( + async (code: string): Promise => { + if (order == null || accessToken == null) return + setErrors([]) + setIsApplying(true) + try { + await applyGiftCard({ accessToken, interceptors, order, giftCardCode: code }) + setCode("") + setInputRequested(false) + await getOrder(order.id) + } catch (error) { + setErrors([toGiftCardError(error)]) + } finally { + setIsApplying(false) + } + }, + [accessToken, interceptors, order, getOrder] + ) + + const remove = useCallback( + async (paymentSessionId: string): Promise => { + if (order == null || accessToken == null) return + setErrors([]) + try { + await removeGiftCard({ accessToken, interceptors, order, paymentSessionId }) + await getOrder(order.id) + } catch (error) { + setErrors([toGiftCardError(error)]) + } + }, + [accessToken, interceptors, order, getOrder] + ) + + if (paymentsModel !== "payment_sessions" || state.giftCardSettingId == null) return null + + return ( + + {children} + + ) +} + +/** + * Both a code we recognise and the message the API sent. + * + * The API collapses four different causes — no such code, expired, empty, bound + * to another market — into one message, so we cannot tell the shopper *why*. + * A translated consumer keys off `code`; one that wants the detail shows + * `message`. + */ +function toGiftCardError(error: unknown): BaseError { + const message = + error instanceof Error + ? error.message + : "This gift card code could not be applied to the order." + return { code: "INVALID_FIELD_VALUE", resource: "gift_cards", field: "gift_card_code", message } +} + +export default PaymentSettingGiftCard diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardAddButton.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardAddButton.tsx new file mode 100644 index 00000000..868c5764 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardAddButton.tsx @@ -0,0 +1,44 @@ +import { type JSX, type ReactNode, useContext } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingGiftCardContext from "#context/PaymentSettingGiftCardContext" +import type { ChildrenFunction } from "#typings/index" + +interface ChildrenProps extends Omit { + handleClick: () => void +} + +type Props = { + children?: ChildrenFunction + label?: string | ReactNode +} & Omit + +/** + * Brings the code input back after a gift card has been applied. + * + * Renders only when another card could actually be applied and the input is + * currently hidden, so it and `` are never both on + * screen — and neither appears once the order is covered. + */ +export function PaymentSettingGiftCardAddButton(props: Props): JSX.Element | null { + const { children, label = "Add another gift card", ...p } = props + const { canAddGiftCard, isInputVisible, showInput, readonly } = useContext( + PaymentSettingGiftCardContext + ) + + if (readonly === true || canAddGiftCard !== true || isInputVisible === true) return null + + const handleClick = (): void => { + showInput?.() + } + const parentProps = { ...props, handleClick } + + return children ? ( + {children} + ) : ( + + ) +} + +export default PaymentSettingGiftCardAddButton diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardInput.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardInput.tsx new file mode 100644 index 00000000..0608af47 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardInput.tsx @@ -0,0 +1,57 @@ +import { type ChangeEvent, type JSX, useContext } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingGiftCardContext from "#context/PaymentSettingGiftCardContext" +import type { ChildrenFunction } from "#typings/index" + +interface ChildrenProps extends Omit { + value: string + handleChange: (event: ChangeEvent) => void + disabled: boolean +} + +type Props = { + children?: ChildrenFunction +} & Omit + +/** + * Where the shopper types a gift card code. + * + * Renders nothing when another card cannot be applied — the order is already + * covered, or something has been authorized — and nothing in readonly mode. + * That rule lives here rather than in the consuming application because + * applying a card that is not needed fails with a 422 the shopper cannot make + * sense of. + * + * After the first card the input steps aside for + * ``, so a shopper who is done is not left + * looking at an empty field. + */ +export function PaymentSettingGiftCardInput(props: Props): JSX.Element | null { + const { children, ...p } = props + const { canAddGiftCard, isInputVisible, isApplying, readonly, code, setCode } = useContext( + PaymentSettingGiftCardContext + ) + + if (readonly === true || canAddGiftCard !== true || isInputVisible !== true) return null + + const handleChange = (event: ChangeEvent): void => { + setCode?.(event.target.value) + } + const disabled = isApplying === true + const parentProps = { ...props, value: code ?? "", handleChange, disabled } + + return children ? ( + {children} + ) : ( + + ) +} + +export default PaymentSettingGiftCardInput diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardList.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardList.tsx new file mode 100644 index 00000000..22e68598 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardList.tsx @@ -0,0 +1,52 @@ +import { hasLiveAuthorization } from "@commercelayer/core-components" +import { type JSX, type ReactNode, useContext } from "react" +import PaymentSettingGiftCardContext from "#context/PaymentSettingGiftCardContext" +import PaymentSettingGiftCardItemContext from "#context/PaymentSettingGiftCardItemContext" + +interface Props { + children?: ReactNode +} + +/** + * Renders `children` once per applied gift card, oldest first. + * + * Shows only cards that are actually paying for something: one whose + * authorization failed, or that has been refunded, took no money, and listing + * it would tell the shopper a payment is in place when none is. + * + * Stays visible in readonly mode and when the order is fully covered — what the + * shopper applied is exactly what they need to see then. Only the controls that + * change things disappear. + */ +export function PaymentSettingGiftCardList({ children }: Props): JSX.Element | null { + const { giftCardSessions, removeGiftCard, readonly } = useContext(PaymentSettingGiftCardContext) + + if (giftCardSessions == null || giftCardSessions.length === 0) return null + + return ( + <> + {giftCardSessions.map((paymentSession) => ( + { + await removeGiftCard?.(paymentSession.id) + }, + }} + > + {children} + + ))} + + ) +} + +export default PaymentSettingGiftCardList diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardListItem.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardListItem.tsx new file mode 100644 index 00000000..e486c54b --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardListItem.tsx @@ -0,0 +1,59 @@ +import type { JSX } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingGiftCardItemContext, { + type InitialPaymentSettingGiftCardItemContext, +} from "#context/PaymentSettingGiftCardItemContext" +import type { ChildrenFunction } from "#typings/index" +import useCustomContext from "#utils/hooks/useCustomContext" + +interface ChildrenProps + extends Omit, + Omit {} + +interface Props extends Omit { + children?: ChildrenFunction +} + +/** + * One applied gift card. + * + * The code and the amount come through as render-prop arguments rather than as + * components of their own: `formattedAmount` is formatted by the API, so a + * component would only be re-wrapping a string, and consumers style these two + * fields differently anyway. + * + * Note the amount is what this card covers of the order, **not** the card's + * balance. The server caps a gift card session at whatever was still owed, and + * the balance is not served on a session at all. + */ +export function PaymentSettingGiftCardListItem(props: Props): JSX.Element { + const { children, ...p } = props + const { paymentSession, code, formattedAmount, amountCents, isRemovable, isRemoving } = + useCustomContext({ + context: PaymentSettingGiftCardItemContext, + contextComponentName: "PaymentSettingGiftCardList", + currentComponentName: "PaymentSettingGiftCardListItem", + key: "paymentSession", + }) + + const parentProps = { + ...props, + paymentSession, + code, + formattedAmount, + amountCents, + isRemovable, + isRemoving, + } + + return children ? ( + {children} + ) : ( +
+ {code} + {formattedAmount} +
+ ) +} + +export default PaymentSettingGiftCardListItem diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardRemoveButton.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardRemoveButton.tsx new file mode 100644 index 00000000..fa5fcca8 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardRemoveButton.tsx @@ -0,0 +1,59 @@ +import { type JSX, type ReactNode, useContext, useState } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingGiftCardItemContext from "#context/PaymentSettingGiftCardItemContext" +import type { ChildrenFunction } from "#typings/index" + +interface ChildrenProps extends Omit { + handleClick: () => Promise + disabled: boolean +} + +type Props = { + children?: ChildrenFunction + label?: string | ReactNode +} & Omit + +/** + * Takes one gift card back off the order. + * + * Renders nothing once the card has been charged. Authorizing a gift card + * debits the balance straight away — the setting forces auto-capture — and from + * there only a refund could return it, which this iteration does not implement. + * The API would refuse the delete anyway, and surfaces that refusal as an + * unhandled 500, so offering the control would be offering a crash. + */ +export function PaymentSettingGiftCardRemoveButton(props: Props): JSX.Element | null { + const { children, label = "Remove", ...p } = props + const { isRemovable, removeGiftCard } = useContext(PaymentSettingGiftCardItemContext) + const [isRemoving, setIsRemoving] = useState(false) + + if (isRemovable !== true) return null + + const handleClick = async (): Promise => { + if (isRemoving) return + setIsRemoving(true) + try { + await removeGiftCard?.() + } finally { + setIsRemoving(false) + } + } + const parentProps = { ...props, handleClick, disabled: isRemoving } + + return children ? ( + {children} + ) : ( + + ) +} + +export default PaymentSettingGiftCardRemoveButton diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardSubmitButton.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardSubmitButton.tsx new file mode 100644 index 00000000..1d168131 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardSubmitButton.tsx @@ -0,0 +1,56 @@ +import { type JSX, type ReactNode, useContext } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingGiftCardContext from "#context/PaymentSettingGiftCardContext" +import type { ChildrenFunction } from "#typings/index" + +interface ChildrenProps extends Omit { + handleClick: () => Promise + disabled: boolean +} + +type Props = { + children?: ChildrenFunction + label?: string | ReactNode +} & Omit + +/** + * Applies the code that has been typed. + * + * Rendered and hidden on the same conditions as the input, so the pair never + * comes apart. Disabled while empty or while a request is in flight — an empty + * code fails a length validation server-side, which is not worth a round trip. + */ +export function PaymentSettingGiftCardSubmitButton(props: Props): JSX.Element | null { + const { children, label = "Apply", ...p } = props + const { canAddGiftCard, isInputVisible, isApplying, readonly, code, applyGiftCard } = useContext( + PaymentSettingGiftCardContext + ) + + if (readonly === true || canAddGiftCard !== true || isInputVisible !== true) return null + + const trimmed = (code ?? "").trim() + const disabled = isApplying === true || trimmed === "" + + const handleClick = async (): Promise => { + if (disabled) return + await applyGiftCard?.(trimmed) + } + const parentProps = { ...props, handleClick, disabled } + + return children ? ( + {children} + ) : ( + + ) +} + +export default PaymentSettingGiftCardSubmitButton diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingRadioButton.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingRadioButton.tsx index 8ebc5842..9ae6455c 100644 --- a/packages/react-components/src/components/payment_settings/PaymentSettingRadioButton.tsx +++ b/packages/react-components/src/components/payment_settings/PaymentSettingRadioButton.tsx @@ -22,15 +22,19 @@ type Props = { * API stored. The cost is that the radio does not light up on click — it lights * up once the session exists. `disabled` covers that round trip. */ -export function PaymentSettingRadioButton(props: Props): JSX.Element { +export function PaymentSettingRadioButton(props: Props): JSX.Element | null { const { children, ...p } = props - const { setting, isSelected, isPending, selectSetting } = useCustomContext({ + const { setting, isSelected, isPending, selectSetting, readonly } = useCustomContext({ context: PaymentSettingChildrenContext, contextComponentName: "PaymentSetting", currentComponentName: "PaymentSettingRadioButton", key: "setting", }) + // A recap has nothing to pick, and a disabled checked radio reads as a broken + // control rather than a statement. The setting's name is the recap. + if (readonly === true) return null + const checked = isSelected === true const id = setting?.id diff --git a/packages/react-components/src/context/PaymentSettingChildrenContext.ts b/packages/react-components/src/context/PaymentSettingChildrenContext.ts index 30145bd5..bfb40f8c 100644 --- a/packages/react-components/src/context/PaymentSettingChildrenContext.ts +++ b/packages/react-components/src/context/PaymentSettingChildrenContext.ts @@ -22,6 +22,8 @@ export interface InitialPaymentSettingChildrenContext { errors?: BaseError[] /** Select this setting, creating or adopting its Payment Session. */ selectSetting?: () => Promise + /** Nothing may change — the subtree is a recap, not a form. */ + readonly?: boolean } const initial: InitialPaymentSettingChildrenContext = {} diff --git a/packages/react-components/src/context/PaymentSettingGiftCardContext.ts b/packages/react-components/src/context/PaymentSettingGiftCardContext.ts new file mode 100644 index 00000000..0e27f5ef --- /dev/null +++ b/packages/react-components/src/context/PaymentSettingGiftCardContext.ts @@ -0,0 +1,55 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { createContext } from "react" +import type { BaseError } from "#typings/errors" + +export interface InitialPaymentSettingGiftCardContext { + /** Gift cards applied to the order, oldest first. */ + giftCardSessions?: PaymentSession[] + /** Sum of the applied gift cards, at face value. */ + giftCardAmountCents?: number + /** What is still owed after the applied gift cards. */ + remainingAmountCents?: number + /** True when the gift cards cover the order outright. */ + isCovered?: boolean + /** + * Whether another gift card may be applied. False once the order is covered, + * and false once anything has been authorized — settling a partially-paid + * order is not implemented. + */ + canAddGiftCard?: boolean + /** Whether the shopper has asked for the input, or it is showing by default. */ + isInputVisible?: boolean + /** Show the input. Used by the "add another" control. */ + showInput?: () => void + /** A code is being applied right now. */ + isApplying?: boolean + /** + * The code being typed. + * + * Held here rather than inside the input because the submit control is a + * separate component and needs to read it. The alternative the older gift + * card form uses — a `
` reading the DOM on submit — is not available + * here: this subtree sits inside a payment step that may already be in a + * form, and nested forms are invalid. + */ + code?: string + setCode?: (code: string) => void + /** + * Gift card errors only. Kept apart from the method's errors: the two + * families have separate UIs, so a failure in one must never surface under + * the other. + */ + errors?: BaseError[] + /** Apply a gift card code to the order. */ + applyGiftCard?: (code: string) => Promise + /** Take one of the applied gift cards off the order. */ + removeGiftCard?: (paymentSessionId: string) => Promise + /** Nothing may be applied or removed — a placed order, for instance. */ + readonly?: boolean +} + +const initial: InitialPaymentSettingGiftCardContext = {} + +const PaymentSettingGiftCardContext = createContext(initial) + +export default PaymentSettingGiftCardContext diff --git a/packages/react-components/src/context/PaymentSettingGiftCardItemContext.ts b/packages/react-components/src/context/PaymentSettingGiftCardItemContext.ts new file mode 100644 index 00000000..28c11115 --- /dev/null +++ b/packages/react-components/src/context/PaymentSettingGiftCardItemContext.ts @@ -0,0 +1,33 @@ +import type { PaymentSession } from "@commercelayer/sdk" +import { createContext } from "react" + +export interface InitialPaymentSettingGiftCardItemContext { + /** The Payment Session this row represents. */ + paymentSession?: PaymentSession + /** The code the shopper typed, as the API stores it on the session. */ + code?: string | null + /** + * How much of the order this card covers, already formatted by the API. Note + * this is *not* the card's balance: the server caps the session at whatever + * was still owed, and the balance is not served on a session at all. + */ + formattedAmount?: string | null + amountCents?: number | null + /** + * Whether this card can still be taken off the order. False once it has been + * charged: authorizing a gift card debits the balance immediately, and only a + * refund could give it back — which this iteration does not implement. + */ + isRemovable?: boolean + /** A removal is in flight for this row. */ + isRemoving?: boolean + /** Take this gift card off the order. */ + removeGiftCard?: () => Promise +} + +const initial: InitialPaymentSettingGiftCardItemContext = {} + +const PaymentSettingGiftCardItemContext = + createContext(initial) + +export default PaymentSettingGiftCardItemContext diff --git a/packages/react-components/src/hooks/usePaymentSessionsState.ts b/packages/react-components/src/hooks/usePaymentSessionsState.ts new file mode 100644 index 00000000..5134bd61 --- /dev/null +++ b/packages/react-components/src/hooks/usePaymentSessionsState.ts @@ -0,0 +1,28 @@ +import { + derivePaymentSessionsState, + type PaymentSessionsState, +} from "@commercelayer/core-components" +import { useContext, useMemo } from "react" +import OrderContext from "#context/OrderContext" + +export type { PaymentSessionsState } + +/** + * Everything the payment UI needs to know about the order's Payment Sessions: + * the applied gift cards, what is still owed, whether anything is left to pay, + * and which session pays the difference. + * + * Exported because five different places need the same numbers — the gift card + * components, the method selector, ``, ``, and a + * consuming application deciding whether payment is still required. Deriving + * them separately is how those end up disagreeing. + * + * The rule lives in `derivePaymentSessionsState`, so an application's own data + * layer reaches the same answer without a hook. This is only the React binding. + */ +export function usePaymentSessionsState(): PaymentSessionsState { + const { order } = useContext(OrderContext) + return useMemo(() => derivePaymentSessionsState(order), [order]) +} + +export default usePaymentSessionsState diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts index c0143403..e80dfcee 100644 --- a/packages/react-components/src/index.ts +++ b/packages/react-components/src/index.ts @@ -86,6 +86,13 @@ export * from "#components/payment_methods/PaymentMethodPrice" export * from "#components/payment_methods/PaymentMethodRadioButton" export * from "#components/payment_methods/PaymentMethodsContainer" export * from "#components/payment_settings/PaymentSetting" +export * from "#components/payment_settings/PaymentSettingGiftCard" +export * from "#components/payment_settings/PaymentSettingGiftCardAddButton" +export * from "#components/payment_settings/PaymentSettingGiftCardInput" +export * from "#components/payment_settings/PaymentSettingGiftCardList" +export * from "#components/payment_settings/PaymentSettingGiftCardListItem" +export * from "#components/payment_settings/PaymentSettingGiftCardRemoveButton" +export * from "#components/payment_settings/PaymentSettingGiftCardSubmitButton" export * from "#components/payment_settings/PaymentSettingManualPayment" export * from "#components/payment_settings/PaymentSettingName" export * from "#components/payment_settings/PaymentSettingRadioButton" @@ -122,6 +129,7 @@ export * from "#components/stock_transfers/StockTransferField" export * from "#hooks/useCommerceLayer" export * from "#hooks/useCustomerContainer" export * from "#hooks/useOrderContainer" +export * from "#hooks/usePaymentSessionsState" export * from "#hooks/usePaymentsModel" export * from "#hooks/useTermsAndConditions" export * from "#typings/errors" From 9d440bb23b322c575e348f3ceaabd48d18c21b01 Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:41:17 +0200 Subject: [PATCH 15/21] fix: let PaymentSetting skip gift card settings silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gift cards are handled by , so the radio group filters them out — but it was doing so through the "not implemented yet" branch and warning about them in development. They are implemented; they just live elsewhere, because they are additive rather than one of the alternatives the group picks between. Co-Authored-By: Claude Opus 5 (1M context) --- packages/react-components/_vitest.config.mts | 4 +++- .../payment_settings/PaymentSetting.spec.tsx | 17 +++++++++++++++++ .../payment_settings/PaymentSetting.tsx | 6 ++++++ packages/react-components/vitest.config.mts | 4 +++- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/react-components/_vitest.config.mts b/packages/react-components/_vitest.config.mts index 64608a17..a2766ba4 100644 --- a/packages/react-components/_vitest.config.mts +++ b/packages/react-components/_vitest.config.mts @@ -6,7 +6,9 @@ import { defineConfig } from "vitest/config" export default defineConfig({ resolve: { alias: { - "@commercelayer/react-hooks-components": path.resolve("../react-hooks-components/src/index.ts"), + "@commercelayer/react-hooks-components": path.resolve( + "../react-hooks-components/src/index.ts" + ), "@commercelayer/core-components": path.resolve("../core-components/src/index.ts"), "#components": path.resolve(__dirname, "src/components"), "#components/auth": path.resolve(__dirname, "src/components/auth"), diff --git a/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx b/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx index ac8e6e78..1c7741c4 100644 --- a/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx +++ b/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx @@ -275,4 +275,21 @@ describe("PaymentSetting", () => { expect(screen.getByTestId("name").textContent).toBe("Bank transfer") }) }) + + // Gift cards live in : additive, not one of the + // alternatives this group picks between. Skipped without a warning, unlike a + // setting that genuinely has no implementation. + it("leaves gift card settings to their own component, silently", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined) + renderSettings( + order({ + available_payment_settings: [ + MANUAL, + { id: "ps-gift", type: "payment_setting_gift_cards", name: "Gift card" }, + ], + } as never) + ) + expect(screen.getAllByTestId("name")).toHaveLength(1) + expect(warn).not.toHaveBeenCalled() + }) }) diff --git a/packages/react-components/src/components/payment_settings/PaymentSetting.tsx b/packages/react-components/src/components/payment_settings/PaymentSetting.tsx index 19c9c591..922393b4 100644 --- a/packages/react-components/src/components/payment_settings/PaymentSetting.tsx +++ b/packages/react-components/src/components/payment_settings/PaymentSetting.tsx @@ -2,6 +2,7 @@ import { createPaymentSession, findCurrentPaymentSession, findReusablePaymentSession, + GIFT_CARD_SETTING_TYPE, } from "@commercelayer/core-components" import type { Order, @@ -103,6 +104,11 @@ export function PaymentSetting({ children, onSelect, readonly }: Props): JSX.Ele if (readonly !== true && isCovered) return null const settings = (order.available_payment_settings ?? []).filter((setting) => { + // Gift cards are handled by , not here: they are + // additive rather than one of the alternatives this group picks between. + // Skipped silently — they are implemented, just elsewhere. + if (setting.type === GIFT_CARD_SETTING_TYPE) return false + const implemented = IMPLEMENTED_SETTING_TYPES.includes( setting.type as (typeof IMPLEMENTED_SETTING_TYPES)[number] ) diff --git a/packages/react-components/vitest.config.mts b/packages/react-components/vitest.config.mts index f590f48b..4e480298 100644 --- a/packages/react-components/vitest.config.mts +++ b/packages/react-components/vitest.config.mts @@ -11,7 +11,9 @@ export default defineConfig({ react: path.resolve(__dirname, "node_modules/react"), "react-dom": path.resolve(__dirname, "node_modules/react-dom"), swr: path.resolve(__dirname, "node_modules/swr"), - "@commercelayer/react-hooks-components": path.resolve("../react-hooks-components/src/index.ts"), + "@commercelayer/react-hooks-components": path.resolve( + "../react-hooks-components/src/index.ts" + ), "@commercelayer/core-components": path.resolve("../core-components/src/index.ts"), "#sdk": path.resolve(__dirname, "../core-components/src/sdk/index.ts"), "#types": path.resolve(__dirname, "../core-components/src/types/index.ts"), From 04c302a2affdfe78c41ccfcd66f634e6643774dc Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:47:38 +0200 Subject: [PATCH 16/21] docs: record the gift card lifecycle on the payment_sessions model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gift card is a payment here, not a discount: the order total never moves and what drops is the amount still owed. That inverts the older model, where the gift card was a negative line item and the total already came back net. The new ADR carries the facts that cost this iteration the most to establish: session_amount_cents does not move until authorization, so the remainder has to be derived; the second gift card would otherwise be sized for the whole order, which is why it is the one place an explicit amount_cents is sent (and why the playground's version over-covers); authorizing a gift card charges it immediately and a void always fails by construction; and deleting a charged session comes back as an unhandled 500. Updates the manual-setting ADR where this supersedes it — the amount_cents rule, the deletion rule reformulated as "only what took no money", the shared-error debt closed, the setting table — and extends CONTEXT.md with Applied Gift Card, Remaining Amount and Coverage. Also corrects the Current Payment Session entry, which still claimed its amount equals the order total. With gift cards it covers the remainder, and it is never a gift card session. Co-Authored-By: Claude Opus 5 (1M context) --- CONTEXT.md | 32 ++- .../2026-08-18-payment-session-lifecycle.md | 40 ++- ...26-08-20-gift-cards-as-payment-sessions.md | 247 ++++++++++++++++++ 3 files changed, 304 insertions(+), 15 deletions(-) create mode 100644 docs/adr/2026-08-20-gift-cards-as-payment-sessions.md diff --git a/CONTEXT.md b/CONTEXT.md index c2e0973f..3b1f2e47 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -30,8 +30,8 @@ The record proving a Payment Session's money was actually taken. A session is on _Avoid_: authorized session, payment (as a synonym for the session) **Current Payment Session**: -The Payment Session the shopper's selection points at — the one whose Payment Setting is the selected one and whose `amount_cents` still equals the order total. It is the shopper's choice made durable: the order has no `payment_setting` relationship, so the selection is read back as `order.payment_sessions[].payment_setting`. Browser state is a rendering cache of it, never the authority. -_Avoid_: pending session, selected payment method +The Payment Session the shopper's selection points at — the one paying whatever the Applied Gift Cards do not. It is the shopper's choice made durable: the order has no `payment_setting` relationship, so the selection is read back as `order.payment_sessions[].payment_setting`. Browser state is a rendering cache of it, never the authority. There is at most one, it is the most recent live non-gift-card session, and **gift card sessions are never it** — those are additive, and several are live at once. Its `amount_cents` covers the Remaining Amount at the moment it was created, not the order total. +_Avoid_: pending session, selected payment method, "the payment session" (an order has several) **Placeable**: Whether the API would accept placing the order. Two distinct things share the word: `order.placeable` and the `_placeable` trigger — but they are **not** two ways to ask the same question, and only one of them is usable. `order.placeable` is transient and served **only in an update response**, never on a `GET`, so it can never gate a button on render. The `_placeable` trigger is a `PATCH` that *validates* the order: 200 with the whole order on success, 422 with a JSON:API `errors` array on failure. Because a failed validation persists nothing, repeating it is cheap. Payment coverage is checked by a default payment rule whose threshold an organization can change — so placeability is a server judgement, never a client calculation. @@ -41,6 +41,18 @@ _Avoid_: "can be placed" (ambiguous between the attribute and the check), valida A Payment Session the library may adopt instead of creating a new one: same Payment Setting, `status` still `unpaid`, not past `expires_at`, and with no Payment Authorization in a terminal failure state. Anything failing that predicate is abandoned in place, not deleted — an `unpaid` session counts toward nothing, and a sales-channel token may be refused the delete anyway. _Avoid_: pending session, stale session, orphan session (the last one is what an abandoned session *becomes*) +**Applied Gift Card**: +A gift card the shopper has spent on an order — a Payment Session against a `payment_setting_gift_cards` setting. Additive rather than an alternative: an order carries zero or more of them *plus* at most one other session for the difference. Its `amount_cents` is what it covers **of this order**, capped by the server to whatever was still owed — never the card's balance, which a session does not carry at all. Removable for free until it is authorized; after that only a refund could return the money, and the balance is debited the instant the authorization succeeds. +_Avoid_: gift card discount (it is a payment, not a discount), gift card balance (a different number) + +**Remaining Amount**: +What the shopper still has to pay: the order total, less the Applied Gift Cards at face value, less any other session that has already taken money. Derived on the client, because `order.session_amount_cents` — despite its name meaning the same thing — does not move until a session is authorized, and gift cards are authorized at place time. The figures summed are the ones the server computed per session. +_Avoid_: session amount (the API's name for the same idea, but only after authorization), balance, outstanding total + +**Coverage**: +Whether the Remaining Amount has reached zero, so no other payment method is needed. Requires the order total to be known: an order fetched without `total_amount_with_taxes_cents` looks free, and treating that as covered hides the whole payment step. +_Avoid_: paid (coverage is intent; only a succeeded Payment Authorization means paid), fully funded + **Undetermined**: The window in which the order has not yet been loaded with the relationship needed to tell the Payments Model apart, so neither payment tree may be mounted. It is an observable state that every consumer must render something for — not a transient detail that can be ignored. _Avoid_: loading (the order may well be loaded; what is missing is the include), unknown @@ -62,6 +74,9 @@ _Avoid_: using "gateway" to mean the Payment Method - A **Payment Session** has at most one **Payment Authorization**; a session without one has taken no money, and one whose authorization failed stays `unpaid` and is abandoned rather than deleted - A **Payment Session** only counts toward the order's paid amount once its **Payment Authorization** has succeeded — and for a manual Payment Setting that happens in a background job, so it is never immediate - An **Order** on the `payment_sessions` model can still carry `available_payment_methods`: API version `2026-05` is additive, and the new model simply takes precedence +- On the `payment_sessions` model a gift card is a **Payment Session**, so it never changes `order.total_amount_with_taxes_cents` — unlike the older model, where it was a negative line item and the total already came back net +- An **Order** carries zero or more **Applied Gift Cards** and at most one other **Payment Session**; that is the only split payment supported +- Changing the Applied Gift Cards invalidates the other **Payment Session**: its `amount_cents` is fixed at creation, so once the **Remaining Amount** moves that session is not stale but wrong - A **Customer Payment Source** belongs to a **Customer**; selecting one sets the **Order**'s Payment Source ## Example dialogue @@ -83,9 +98,20 @@ _Avoid_: using "gateway" to mean the Payment Method ## Flagged ambiguities -- ~~Gift cards on the `payment_sessions` model are undecided and out of scope.~~ **Resolved.** A gift card is spent by creating a Payment Session against a `payment_setting_gift_cards` setting — it is a payment method, not an order-level code. On the `payment_sessions` model `GiftCardOrCouponForm` therefore works on the coupon only, overriding an explicit `codeType="gift_card_code"`. Note the trigger named in the original entry never existed: there is no `_gift_card_or_coupon_code`, only the plain order attribute `gift_card_or_coupon_code`, and the components write `gift_card_code` / `coupon_code` directly. See `docs/adr/2026-08-18-payment-session-lifecycle.md`. +- ~~Gift cards on the `payment_sessions` model are undecided and out of scope.~~ **Resolved.** A gift card is spent by creating a Payment Session against a `payment_setting_gift_cards` setting — it is a payment method, not an order-level code. On the `payment_sessions` model `GiftCardOrCouponForm` therefore works on the coupon only, overriding an explicit `codeType="gift_card_code"`. Note the trigger named in the original entry never existed: there is no `_gift_card_or_coupon_code`, only the plain order attribute `gift_card_or_coupon_code`, and the components write `gift_card_code` / `coupon_code` directly. See `docs/adr/2026-08-20-gift-cards-as-payment-sessions.md` for the full lifecycle. - **Three official sources are wrong about this domain**, so verify against `core-api` rather than the SDK types or the docs: `available_payment_methods` is annotated `@deprecated Last available in API version 2017-08` but is still served on `2026-05`; `payment_type` is documented as `"manual_payment"` but the real values are uppercase (`MANUAL`, `GIFT_CARD`, …); and both `status` fields are typed as bare `string` with a single `@example`, hiding state machines of seven and eight values. - "the session's type" was used to mean the gateway (e.g. "manual") — but `payment_session.type` is always the literal `"payment_sessions"` (the resource type). The gateway is `payment_session.payment_setting.type` (e.g. `payment_setting_manuals`). When someone says "the session type", ask which one they mean. - "the payment is done" was used for both a created **Payment Session** and a taken payment — resolved: only a `succeeded` **Payment Authorization** means paid; a session on its own means nothing was taken. - "placeable" was used for both the readable order attribute and the `_placeable` validation trigger — resolved in the glossary above; when someone says "check if it's placeable", ask whether they mean reading the attribute or asking the API. - "set payment source" was used to mean both the async operation that creates/attaches a Payment Source *and* the reducer action that stores it in state — resolved: the operation is `setPaymentSource(...)`, the reducer action is `dispatch({ type: "setPaymentSource" })`. + +## Example dialogue — gift cards + +> **Dev:** "The shopper applied a $50 gift card on a $71 order. Why is the API still saying $71 is left?" +> **Domain expert:** "Because nothing has been authorized yet. `session_amount_cents` only drops once a session has taken money, and gift cards are authorized at place time so they can still be removed. The **Remaining Amount** you show is derived on the client." + +> **Dev:** "So for the second gift card I just leave `amount_cents` out again?" +> **Domain expert:** "No — that is the one exception. The server would size it against a remainder that has not moved, so it would ask for the whole order. Send the real remainder; the server clamps it down if it disagrees, never up." + +> **Dev:** "The shopper wants to take a gift card off, but the order was already placed. Do I delete the session?" +> **Domain expert:** "You cannot. Authorizing a gift card debits the balance straight away, and the API refuses to delete a session with transactions on it — it comes back as a 500. Only a refund would return that money, and we do not implement one, so the remove control is not rendered at all." diff --git a/docs/adr/2026-08-18-payment-session-lifecycle.md b/docs/adr/2026-08-18-payment-session-lifecycle.md index 54b16295..1438fb60 100644 --- a/docs/adr/2026-08-18-payment-session-lifecycle.md +++ b/docs/adr/2026-08-18-payment-session-lifecycle.md @@ -87,6 +87,12 @@ session — that is what makes it survive a reload. Create with **`payment_setting` and `order` only**. Never send `amount_cents`; let the server size the session against the remaining amount. +> **Superseded in one place.** Gift cards after the first *do* send an explicit +> `amount_cents`, because the server's remainder does not move until a session is +> authorized and gift cards are authorized at place time — so it would size every card for +> the whole order. See `2026-08-20-gift-cards-as-payment-sessions.md`. The rule above still +> holds for the session paying the difference, where the server must work out what is left. + Before creating, **reuse** an existing session when all of these hold: - its `payment_setting.id` matches the selected setting, **and** @@ -118,6 +124,14 @@ covered by tests, is the retry path — a burnt session does not count as the se clicking again creates a fresh one. The adopt branch is kept because it becomes live as soon as a second setting exists. +### Sessions that took no money are deleted; everything else is abandoned + +> **Reformulated** by `2026-08-20-gift-cards-as-payment-sessions.md`. The constraint behind +> the original "never delete" was that a burnt session might not be deletable — not that +> deletion is always wrong. Removing a gift card, and invalidating a session whose amount is +> no longer correct, both require deleting; both only ever touch sessions that have taken +> nothing. + ### Failed sessions are abandoned, not deleted A session whose authorization failed stays `unpaid`, so it is outside @@ -196,14 +210,10 @@ conditionally will see an order whose `payment_sessions` never expand. ### Known debt, due with the second setting -`` holds **one** `errors` state for the whole list, not one per setting, -while `isPending` is correctly per-setting. With only the manual setting implemented this -is invisible; the moment a second one exists, an error raised while selecting Stripe will -render under the manual entry too. - -Not fixed yet because the clean fix is a `` component, and a public -component invented for a problem no one can observe is harder to remove than to add. -Whoever implements the second setting should fix this first. +~~`` holds one `errors` state for the whole list.~~ **Closed** by +`2026-08-20-gift-cards-as-payment-sessions.md`: gift card errors live on their own context, +because gift cards and the method turned out to be two disjoint sets with two separate UIs +rather than two entries in one list. No `` was needed. **Auto-selecting a single setting is deliberately absent.** `` offers `autoSelectSinglePaymentMethod` and the symmetric prop belongs here, but the obvious @@ -235,21 +245,27 @@ is precisely why neither may act without the shopper. | Adyen | `payment_setting_adyens` | ⬜ not implemented | | Braintree | `payment_setting_braintrees` | ⬜ not implemented | | External | `payment_setting_externals` | ⬜ not implemented | -| Gift card | `payment_setting_gift_cards` | ⬜ not implemented | +| Gift card | `payment_setting_gift_cards` | ✅ implemented — see `2026-08-20-gift-cards-as-payment-sessions.md` | ### Gift cards On this model a gift card is spent by creating a Payment Session against a -`payment_setting_gift_cards` setting, carrying `gift_card_code` and no `amount_cents`. -It is therefore a payment method, not an order-level code. +`payment_setting_gift_cards` setting. It is therefore a payment, not an order-level code — +the full lifecycle is in `2026-08-20-gift-cards-as-payment-sessions.md`. -Until that setting is implemented, `GiftCardOrCouponForm` pins its code type to +`GiftCardOrCouponForm` pins its code type to `coupon_code` whenever the model is `payment_sessions` — **overriding an explicit `codeType="gift_card_code"` prop**. Writing `gift_card_code` on the order is meaningless here, and allowing it through would silently apply a gift card that no session reflects. `GiftCardOrCouponCode` and `GiftCardOrCouponRemoveButton` follow the same rule; their `managePaymentProviderGiftCards` branch is Adyen-specific and unreachable on this model. +The coupon and the gift card end up in different places on this model, and deliberately: +the coupon is a discount and belongs with the order summary, while the gift card is a +payment and belongs in the payment step. Putting a control that creates a payment session +next to the discounts would have the total drop as if it were a coupon, while the card is +in fact charged at place time. + This supersedes the "gift cards are undecided and out of scope" ambiguity previously flagged in `CONTEXT.md`. diff --git a/docs/adr/2026-08-20-gift-cards-as-payment-sessions.md b/docs/adr/2026-08-20-gift-cards-as-payment-sessions.md new file mode 100644 index 00000000..406556f0 --- /dev/null +++ b/docs/adr/2026-08-20-gift-cards-as-payment-sessions.md @@ -0,0 +1,247 @@ +# Gift cards as Payment Sessions + +## Context + +On the `payment_sessions` model a gift card is a **payment**, not a discount. It is spent by +creating a Payment Session against a `payment_setting_gift_cards` setting, and the order +total never changes. + +That is the reverse of the older model, where a gift card became a negative line item and +`total_amount_with_taxes_cents` came back already net. The mechanism is switched off +entirely for new payments — `order.gift_card_applicator` returns a null object when +`new_payments?` (`app/models/order.rb:984-987`), and `GiftCardApplicator#call` self-guards +too — so `gift_card_amount_cents` stays at zero. The old `order.gift_card_code` attribute is +additionally hard-blocked once any session exists, raising a 400 `UNSUPPORTED` +(`order_payments.rb:195-198`, `:569-572`). + +This is the **only** split payment this iteration supports: zero or more gift cards, plus at +most one other session for the difference. Anything wider is out of scope. + +### What the API actually does + +Verified in `core-api`. + +**A gift card session is sized `min(remaining, balance)`** — +`app/models/payment/session/gift_card.rb:12-16`: + +```ruby +def default_amount_cents + total = session.session_amount_cents + return total unless gift_card + total > balance_cents ? balance_cents : total +end +``` + +**…but `session_amount_cents` only drops once a session is authorized.** It is +`total_amount_with_taxes_cents - sum(payment_taken sessions)` +(`order_amounts.rb:24-28`), and `payment_taken` is `authorized`/`paid`/`partially_paid`. +Merely applying a gift card moves nothing on the order. + +**Authorizing a gift card charges it immediately and irreversibly-ish.** +`auto_capture?` is hard-coded `true` for the gift card client, so the authorization is +followed straight away by a succeeded capture and the session lands on `paid` +(`payment/session/base.rb:72-92`, `payment_capture.rb:20-22`). `authorize!` calls +`gift_card.use!`, which debits the balance. A **void always fails** by construction — +`void!` does `auto_capture? ? transaction.fail! : transaction.succeed!` — so the only way +back is a `PaymentRefund`, which calls `gift_card.restore!`. + +**Deleting a session is allowed, until it has transactions.** A sales-channel token may +`destroy` a `PaymentSession` while the order is `draft`/`pending`/`editing` +(`sales_channel_ability.rb:20-22`), but has no `destroy` on authorizations, captures or +refunds. And `PaymentSession` declares its transactions +`dependent: :restrict_with_exception` (`payment_session.rb:76-82`), so deleting a charged +session raises `ActiveRecord::DeleteRestrictionError` — which is **not** in the exception +handler and surfaces as an unhandled **500**. Deleting an unauthorized session touches no +balance: nothing was taken. + +**Several gift cards are allowed, with no maximum.** The only constraint is that the same +code cannot be applied twice while the first session is still `unpaid` +(`payment_session.rb:212-221`). + +**Every bad-code reason collapses into one message.** No such code, expired, zero balance, +or bound to another market all fail the same lookup and produce +`gift_card_code: doesn't match any active gift card` (422, `source.pointer` ending +`/gift_card_code`). A duplicate gives `has already been taken`. There is no way to tell the +shopper *which* it was. + +**Once coverage is complete the API gives no clean signal.** Creating another session +defaults its amount to zero and fails `numericality: { greater_than: 0 }` — a 422 about +`amount_cents` that means nothing to a shopper. + +## Decision + +### Two disjoint sets, not one list + +Gift cards are a **list**; the difference is paid by **at most one** other session. +`findCurrentPaymentSession` excludes gift card types entirely, so it keeps governing the +radio group, and `derivePaymentSessionsState` returns the gift cards separately. + +A gift card is not one of the alternatives the shopper picks between — it is applied on top, +and several are live at once. Putting them in the radio group would mean more than one +selection in a group with room for exactly one. + +`` therefore sits **outside** ``, which skips gift +card settings silently: they are implemented, just elsewhere. + +### The remainder is derived, not read + +`order.session_amount_cents` cannot be used: it stays at the full total for as long as the +shopper is choosing, because gift cards are authorized at place time. So +`derivePaymentSessionsState` computes + +``` +remaining = total − Σ(live gift card amounts) − Σ(authorized method amounts) +``` + +Gift cards count as soon as applied; a method session counts only once it has taken money, +because an unauthorized one is an intent, not a payment. + +The amounts summed are the ones the **server** computed per session, so this is arithmetic +over server values rather than a reimplementation of its rules. + +`isCovered` additionally requires `total > 0`. Without that guard an order fetched without +`total_amount_with_taxes_cents` in its `fields` reads as free, and coverage would hide the +entire payment step — which is exactly what happened before a spec caught it. + +### An explicit `amount_cents` from the second card onwards + +This is the one place the "never send `amount_cents`" rule is broken, and it has to be. + +On a $71 order, a $50 card followed by a $100 card produces sessions of $50 and **$71** — +the server's remainder has not moved, so the second card is sized for the whole order. +$121 of credit for a $71 order, and nothing server-side prevents it. + +Sending an amount is safe because `cap_amount_cents` clamps it **down** to what the server +would have allowed and never up, so the server stays the authority on the maximum. And the +number sent is a sum of amounts the server itself computed. + +The rule still holds for the session paying the difference, where the server must be the one +to work out what is left. + +**The `examples-new-payments` playground has this bug.** It applies every card without an +amount (`payment-section.tsx:1082-1106`) and never gates its input on the remainder +(`gift-card-section.tsx:66-79`). It is not a precedent to copy. + +### Applying or removing invalidates the session paying the difference + +`amount_cents` is set once and never updatable, so a session created against a different +remainder is not stale but *wrong*: it would still read as the shopper's selection, and at +place time we would authorize more than is owed. + +Both operations therefore delete it, as part of the same domain operation — binding the +invalidation to the action that causes it, rather than to an effect somewhere comparing +amounts. Effects that delete resources in response to an amount comparison are the class of +code that has produced render loops and duplicate creations in this repo three times. + +The cleanup swallows its own failures: it runs alongside an action the shopper asked for and +can see the result of, and turning a cleanup failure into a visible error would report the +wrong thing. + +### Only what took nothing is deleted + +This reformulates the earlier "never delete" rule without contradicting it. The constraint +behind that rule was that a burnt session might not be deletable — not that deletion is +always wrong. + +**A session is deleted only when it has taken no money; everything else is abandoned.** A +charged gift card cannot be removed: the API would refuse and surface a 500, a sales-channel +token cannot clear the transactions, and only a refund would return the balance — which this +iteration does not implement. Its remove control is therefore not rendered at all, rather +than rendered and failing. + +### Place: gift cards first, stop at the first failure + +`placeOrderWithPaymentSessions` takes the order and authorizes the gift cards in sequence, +then the session paying the difference **if there is one** — gift cards can cover the order +outright. + +Gift cards go first as a client-side safety property; nothing server-side enforces it. Each +authorization shrinks what the next session may take, and a gift card charged after a failed +method payment would leave the shopper's balance spent on an order that never got placed. + +On a failure partway, it stops and reports. The cards already charged stay charged: carrying +on would only charge more for an order that is not going to be placed, and **no rollback is +implemented**. The gift card list is itself the recovery surface — after a reload the shopper +sees which cards were charged and what is left to pay. + +On a **timeout** nothing is touched at all, because the payment may well have succeeded. See +`2026-08-18-place-order-split-by-payments-model.md`. + +### Amounts on screen + +`` deducts the gift cards on this model, restoring parity with the older one +where the total already came back net. It deducts the **gift cards only**, not everything +authorized: on the older model an authorized payment source never reduced the total shown. + +`` sums the sessions, since `gift_card_amount_cents` stays at zero, and +shows the figure as a deduction. + +Both branch off the same derivation as everything else. A consuming application that needs +the number outside these components uses `derivePaymentSessionsState`, so the figures cannot +disagree. + +### Errors in two channels + +Gift card errors live on the gift card context; the method's live on ``. +Not one list filtered by setting: gift cards and the method are two disjoint sets with two +separate UIs, so a failure in one must never surface under the other. This closes the shared +error-state debt that was due with the second setting. + +An error carries both a `code` we chose and the message the API sent, because a translated +consumer needs the former and the API's single collapsed message is the only detail +available. + +## Considered options + +- **One list of sessions with a flag, UI decides.** Rejected: pushes the same distinction + onto every consumer. +- **Full split payment, N sessions of any type.** Rejected: rethinking the whole interaction + for a case explicitly out of scope. +- **Authorize each gift card on entry**, so the server's remainder is always right. + Rejected: it charges the card on entry and turns removal into a refund, losing the one + property that makes the flow forgiving. +- **A single gift card.** Rejected: multiple was a requirement. +- **Leave the stale method session and exclude it by comparing amounts.** Rejected: a second + notion of "valid session" based on a client-side amount comparison, and a session that + looks like the selection but is not. +- **Recreate the method session at place time.** Rejected: moves a deletion into the moment + money is being taken. +- **Read `order.session_amount_cents` for the remainder.** Impossible: it does not move + until authorization. +- **``/`` left alone, with a new remainder component.** + Rejected: the shopper reads the existing figures, and showing them gross is the wrong + number in the place they actually look. + +## Consequences + +**The shopper cannot be told why a code was rejected.** Four different causes arrive as one +message. Not a choice of ours — it is all the API gives. + +**Nothing more can be applied once anything is authorized.** `canAddGiftCard` goes false as +soon as any session carries a live authorization, gift cards included. So after a timed-out +place — where the cards are already `paid` — no further card is accepted. This is the interim +answer while settling a partially-paid order remains undesigned; the target state is the +thank-you page with a payment summary described in the place-order ADR. + +**`` no longer equals `order.total_amount_with_taxes_cents`** on this model. +Consumers doing their own arithmetic must use `derivePaymentSessionsState` rather than the +order attribute, or the page will contradict itself. + +**An order fetched without `total_amount_with_taxes_cents` breaks the amounts.** The +`isCovered` guard stops it hiding the payment step, but the deduction and the remainder both +need the figure. Any consumer with a `fields` allowlist has to include it. + +**Orphan sessions accumulate**, now from two sources: abandoned method sessions and the ones +invalidated on every gift card change. All are `unpaid`, so they are inert and contribute to +nothing — but it is why every read searches the array rather than indexing it. + +### Payment Setting implementation status + +| Setting | Type literal | Status | +| --- | --- | --- | +| Manual | `payment_setting_manuals` | ✅ implemented | +| Gift card | `payment_setting_gift_cards` | ✅ implemented | +| Stripe | `payment_setting_stripes` | ⬜ not implemented | +| Adyen | `payment_setting_adyens` | ⬜ not implemented | +| Braintree | `payment_setting_braintrees` | ⬜ not implemented | +| External | `payment_setting_externals` | ⬜ not implemented | From f706d15771783c6339e50d4922078da483b63d72 Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:33:49 +0200 Subject: [PATCH 17/21] feat: handle partial gift card payments and error messages for invalid giftcards --- .../src/payment_sessions/apiErrors.ts | 34 +++++++ .../createPaymentSession.spec.ts | 69 ++++++++++++++ .../payment_sessions/createPaymentSession.ts | 31 ++++++- .../findReusablePaymentSession.spec.ts | 50 ++++++++++ .../findReusablePaymentSession.ts | 18 ++++ .../src/payment_sessions/index.ts | 1 + .../mapGiftCardErrors.spec.ts | 68 ++++++++++++++ .../src/payment_sessions/mapGiftCardErrors.ts | 48 ++++++++++ .../payment_sessions/mapPlaceabilityErrors.ts | 34 +------ .../PaymentSettingGiftCard.spec.tsx | 92 +++++++++++++++++++ .../payment_settings/PaymentSetting.tsx | 6 +- .../PaymentSettingGiftCard.tsx | 43 +++++++-- .../PaymentSettingGiftCardErrors.tsx | 57 ++++++++++++ packages/react-components/src/index.ts | 1 + 14 files changed, 504 insertions(+), 48 deletions(-) create mode 100644 packages/core-components/src/payment_sessions/apiErrors.ts create mode 100644 packages/core-components/src/payment_sessions/createPaymentSession.spec.ts create mode 100644 packages/core-components/src/payment_sessions/mapGiftCardErrors.spec.ts create mode 100644 packages/core-components/src/payment_sessions/mapGiftCardErrors.ts create mode 100644 packages/react-components/src/components/payment_settings/PaymentSettingGiftCardErrors.tsx diff --git a/packages/core-components/src/payment_sessions/apiErrors.ts b/packages/core-components/src/payment_sessions/apiErrors.ts new file mode 100644 index 00000000..e21804b2 --- /dev/null +++ b/packages/core-components/src/payment_sessions/apiErrors.ts @@ -0,0 +1,34 @@ +/** Shape of a JSON:API error object as the API sends it. */ +export interface ApiErrorObject { + code?: string + title?: string + detail?: string + source?: { pointer?: string } + meta?: { error?: string } +} + +/** + * The SDK surfaces API errors as a thrown object, but the exact wrapper has + * moved between versions, so probe the two shapes rather than assuming one. + * + * Note what is *not* here: `error.message`. On an SDK error that is empty — the + * API's wording only ever lives in this array — so anything reading `message` + * shows the shopper a blank. + */ +export function extractApiErrors(error: unknown): ApiErrorObject[] { + if (error == null || typeof error !== "object") return [] + const candidate = error as { errors?: unknown; response?: { data?: { errors?: unknown } } } + const errors = candidate.errors ?? candidate.response?.data?.errors + return Array.isArray(errors) ? (errors as ApiErrorObject[]) : [] +} + +/** + * `/data/attributes/payment_action` → `payment_action`. + * `/data` (a base error, not tied to an attribute) → `base`. + */ +export function fieldFromPointer(pointer?: string): string | undefined { + if (pointer == null || pointer === "") return undefined + const last = pointer.split("/").pop() + if (last == null || last === "") return undefined + return last === "data" ? "base" : last +} diff --git a/packages/core-components/src/payment_sessions/createPaymentSession.spec.ts b/packages/core-components/src/payment_sessions/createPaymentSession.spec.ts new file mode 100644 index 00000000..da0f7395 --- /dev/null +++ b/packages/core-components/src/payment_sessions/createPaymentSession.spec.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { createPaymentSession } from "./createPaymentSession" + +const { getSdkMock } = vi.hoisted(() => ({ getSdkMock: vi.fn() })) +vi.mock("#sdk", () => ({ getSdk: getSdkMock })) + +const ACCESS_TOKEN = "token" + +function stubSdk(): ReturnType { + const create = vi.fn().mockResolvedValue({ id: "session-new" }) + getSdkMock.mockReturnValue({ + payment_sessions: { create }, + payment_settings: { relationship: vi.fn((id: string) => ({ id, type: "payment_settings" })) }, + orders: { relationship: vi.fn((id: string) => ({ id, type: "orders" })) }, + }) + return create +} + +describe("createPaymentSession", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The server sizes an omitted amount to the full total until the gift cards + // are authorized at place time, so the remainder has to be sent. + it("sends the remainder as amount_cents", async () => { + const create = stubSdk() + + await createPaymentSession({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSettingId: "ps-manual", + amountCents: 5100, + }) + + expect(create).toHaveBeenCalledWith(expect.objectContaining({ amount_cents: 5100 })) + }) + + it("omits amount_cents when the remainder is unknown", async () => { + const create = stubSdk() + + await createPaymentSession({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSettingId: "ps-manual", + }) + + expect(create).toHaveBeenCalledWith( + expect.not.objectContaining({ amount_cents: expect.any(Number) }) + ) + }) + + // `amount_cents` must be greater than zero, so a covered order would get a + // 422 rather than a session — leave the sizing to the server instead. + it.each([0, -100])("omits amount_cents when the remainder is %i", async (amountCents) => { + const create = stubSdk() + + await createPaymentSession({ + accessToken: ACCESS_TOKEN, + orderId: "order-1", + paymentSettingId: "ps-manual", + amountCents, + }) + + expect(create).toHaveBeenCalledWith( + expect.not.objectContaining({ amount_cents: expect.any(Number) }) + ) + }) +}) diff --git a/packages/core-components/src/payment_sessions/createPaymentSession.ts b/packages/core-components/src/payment_sessions/createPaymentSession.ts index b87cb37e..00cae710 100644 --- a/packages/core-components/src/payment_sessions/createPaymentSession.ts +++ b/packages/core-components/src/payment_sessions/createPaymentSession.ts @@ -6,16 +6,32 @@ interface CreatePaymentSessionParams extends Pick { const sdk = getSdk({ accessToken, interceptors }) return await sdk.payment_sessions.create({ payment_setting: sdk.payment_settings.relationship(paymentSettingId), order: sdk.orders.relationship(orderId), + // A zero or negative amount is rejected by the API (`greater_than: 0`), and + // there is nothing left to pay anyway — fall back to the server's own + // sizing rather than sending a value that cannot be valid. + ...(amountCents != null && amountCents > 0 ? { amount_cents: amountCents } : {}), }) } diff --git a/packages/core-components/src/payment_sessions/findReusablePaymentSession.spec.ts b/packages/core-components/src/payment_sessions/findReusablePaymentSession.spec.ts index f22b2f11..5b01b4a9 100644 --- a/packages/core-components/src/payment_sessions/findReusablePaymentSession.spec.ts +++ b/packages/core-components/src/payment_sessions/findReusablePaymentSession.spec.ts @@ -142,3 +142,53 @@ describe("findReusablePaymentSession", () => { ).toBeUndefined() }) }) + +// Applying a gift card moves the remainder, and `amount_cents` is immutable — +// so the session created before it is wrong, not merely stale. The deletion in +// `invalidateCurrentPaymentSession` is best effort, which is why this check +// exists here too. +describe("findReusablePaymentSession amount check", () => { + it("ignores a session sized for a different remainder", () => { + expect( + findReusablePaymentSession({ + paymentSessions: [session({ amount_cents: 7100 })], + paymentSettingId: SETTING_ID, + amountCents: 5100, + now: NOW, + }) + ).toBeUndefined() + }) + + it("adopts a session sized for the current remainder", () => { + const reusable = session({ amount_cents: 5100 }) + expect( + findReusablePaymentSession({ + paymentSessions: [reusable], + paymentSettingId: SETTING_ID, + amountCents: 5100, + now: NOW, + }) + ).toBe(reusable) + }) + + it("still adopts when either amount is unknown", () => { + const noSessionAmount = session() + expect( + findReusablePaymentSession({ + paymentSessions: [noSessionAmount], + paymentSettingId: SETTING_ID, + amountCents: 5100, + now: NOW, + }) + ).toBe(noSessionAmount) + + const withAmount = session({ amount_cents: 7100 }) + expect( + findReusablePaymentSession({ + paymentSessions: [withAmount], + paymentSettingId: SETTING_ID, + now: NOW, + }) + ).toBe(withAmount) + }) +}) diff --git a/packages/core-components/src/payment_sessions/findReusablePaymentSession.ts b/packages/core-components/src/payment_sessions/findReusablePaymentSession.ts index 78dd3e67..535e6ad8 100644 --- a/packages/core-components/src/payment_sessions/findReusablePaymentSession.ts +++ b/packages/core-components/src/payment_sessions/findReusablePaymentSession.ts @@ -6,6 +6,12 @@ interface FindReusablePaymentSessionParams { paymentSessions?: PaymentSession[] | null /** The Payment Setting the shopper selected. */ paymentSettingId: string + /** + * What the session would have to pay, from `derivePaymentSessionsState`. When + * given, a session sized for a different remainder is not adopted. Omit it + * only where the remainder is unknown. + */ + amountCents?: number /** Injectable for tests. Defaults to now. */ now?: Date } @@ -25,15 +31,27 @@ interface FindReusablePaymentSessionParams { * Sessions are always searched for, never read positionally: failed attempts * leave inert `unpaid` sessions behind, and a gift card (or, later, a split * payment) puts other settings' sessions in the same array. + * + * A session sized for a different remainder is not reusable either. Applying a + * gift card is supposed to delete it (`invalidateCurrentPaymentSession`), but + * that deletion is best effort and swallows its failures — so the amount is + * checked here as well, where adopting the wrong one would authorize more than + * is owed. */ export function findReusablePaymentSession({ paymentSessions, paymentSettingId, + amountCents, now = new Date(), }: FindReusablePaymentSessionParams): PaymentSession | undefined { return (paymentSessions ?? []).find((session) => { if (session.payment_setting?.id !== paymentSettingId) return false + // Only when both numbers are known: an order fetched without + // `amount_cents` in its `fields` must not lose reuse altogether. + if (amountCents != null && session.amount_cents != null && session.amount_cents !== amountCents) + return false + // Anything past `unpaid` has taken money (or has been voided/refunded); // in both cases creating or adopting it again would be wrong. if (session.status !== "unpaid") return false diff --git a/packages/core-components/src/payment_sessions/index.ts b/packages/core-components/src/payment_sessions/index.ts index 9b5bb6cb..3f2240ef 100644 --- a/packages/core-components/src/payment_sessions/index.ts +++ b/packages/core-components/src/payment_sessions/index.ts @@ -7,6 +7,7 @@ export { findReusablePaymentSession } from "./findReusablePaymentSession" export type { PaymentsModel } from "./getPaymentsModel" export { getPaymentsModel } from "./getPaymentsModel" export { invalidateCurrentPaymentSession } from "./invalidateCurrentPaymentSession" +export { mapGiftCardErrors } from "./mapGiftCardErrors" export { mapPlaceabilityErrors } from "./mapPlaceabilityErrors" export type { PlaceOrderWithPaymentSessionsResult } from "./placeOrderWithPaymentSessions" export { diff --git a/packages/core-components/src/payment_sessions/mapGiftCardErrors.spec.ts b/packages/core-components/src/payment_sessions/mapGiftCardErrors.spec.ts new file mode 100644 index 00000000..0b45b4be --- /dev/null +++ b/packages/core-components/src/payment_sessions/mapGiftCardErrors.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest" +import { mapGiftCardErrors } from "./mapGiftCardErrors" + +/** A refused gift card, exactly as the API sent it on 2026-08-25. */ +const REFUSED = { + errors: [ + { + title: "doesn't match any active gift card", + detail: "gift_card_code - doesn't match any active gift card", + code: "VALIDATION_ERROR", + source: { pointer: "/data/attributes/gift_card_code" }, + status: "422", + meta: { error: "invalid_gift_card" }, + }, + { + title: "can't be blank", + detail: "token - can't be blank", + code: "VALIDATION_ERROR", + source: { pointer: "/data/attributes/token" }, + status: "422", + meta: { error: "blank" }, + }, + ], +} + +describe("mapGiftCardErrors", () => { + // `detail` prefixes the attribute name, which is for an API client and not + // for a shopper. + it("uses the title rather than the attribute-prefixed detail", () => { + expect(mapGiftCardErrors(REFUSED)).toEqual([ + { + code: "VALIDATION_ERROR", + message: "doesn't match any active gift card", + field: "gift_card_code", + meta: { error: "invalid_gift_card" }, + }, + ]) + }) + + // The token error is a consequence of the session never being built, not + // something the shopper can act on. + it("drops the errors that are not about the code", () => { + expect(mapGiftCardErrors(REFUSED)).toHaveLength(1) + }) + + it("reads the errors through the response wrapper too", () => { + expect(mapGiftCardErrors({ response: { data: REFUSED } })[0]?.message).toBe( + "doesn't match any active gift card" + ) + }) + + // Better an unattributed message than silence. + it("keeps everything when nothing points at the code", () => { + const mapped = mapGiftCardErrors({ + errors: [{ title: "Something else", source: { pointer: "/data" } }], + }) + expect(mapped).toEqual([ + { code: "VALIDATION_ERROR", message: "Something else", field: "gift_card_code" }, + ]) + }) + + it.each([undefined, null, "boom", new Error("boom"), {}, { errors: "nope" }])( + "returns nothing for %s, which carries no API errors", + (error) => { + expect(mapGiftCardErrors(error)).toEqual([]) + } + ) +}) diff --git a/packages/core-components/src/payment_sessions/mapGiftCardErrors.ts b/packages/core-components/src/payment_sessions/mapGiftCardErrors.ts new file mode 100644 index 00000000..0d650f03 --- /dev/null +++ b/packages/core-components/src/payment_sessions/mapGiftCardErrors.ts @@ -0,0 +1,48 @@ +import { extractApiErrors } from "./apiErrors" +import type { PlaceabilityError } from "./types" + +/** The attribute a gift card failure is reported against. */ +const GIFT_CARD_FIELD = "gift_card_code" + +/** + * Turn a refused gift card operation into errors worth showing a shopper. + * + * **Why not just `error.message`.** On an SDK error that is the empty string — + * the API's wording lives only in the JSON:API `errors` array — so reading it + * renders an error box with nothing in it, which is barely better than the + * silence it replaced. + * + * **Why `title` and not `detail`.** The API sends both, and `detail` is + * `title` prefixed with the attribute name: `"gift_card_code - doesn't match + * any active gift card"`. The prefix is for an API client, not a shopper. + * (`mapPlaceabilityErrors` prefers `detail` because there the two are the same + * text and `detail` is the fuller one.) + * + * **Why the other entries are dropped.** A refused gift card comes back as + * *two* errors: the real one, and `"token - can't be blank"` — a consequence of + * the session never being built, not something the shopper did. Showing it + * would be actively misleading. Everything is kept when nothing points at + * `gift_card_code`, so an unrecognised failure is still reported rather than + * swallowed. + * + * The API collapses four causes — unknown code, expired, empty, bound to + * another market — into one message, so `meta.error` (`"invalid_gift_card"`) is + * as specific as this gets. + */ +export function mapGiftCardErrors(error: unknown): PlaceabilityError[] { + const errors = extractApiErrors(error) + if (errors.length === 0) return [] + + const giftCardErrors = errors.filter((apiError) => + apiError.source?.pointer?.endsWith(`/${GIFT_CARD_FIELD}`) + ) + const relevant = giftCardErrors.length > 0 ? giftCardErrors : errors + + return relevant.map((apiError) => ({ + code: apiError.code ?? "VALIDATION_ERROR", + message: + apiError.title ?? apiError.detail ?? "This gift card code could not be applied to the order.", + field: GIFT_CARD_FIELD, + ...(apiError.meta?.error != null ? { meta: { error: apiError.meta.error } } : {}), + })) +} diff --git a/packages/core-components/src/payment_sessions/mapPlaceabilityErrors.ts b/packages/core-components/src/payment_sessions/mapPlaceabilityErrors.ts index 132ce830..b8b5a021 100644 --- a/packages/core-components/src/payment_sessions/mapPlaceabilityErrors.ts +++ b/packages/core-components/src/payment_sessions/mapPlaceabilityErrors.ts @@ -1,14 +1,6 @@ +import { extractApiErrors, fieldFromPointer } from "./apiErrors" import type { PlaceabilityError } from "./types" -/** Shape of a JSON:API error object as the API sends it. */ -interface ApiErrorObject { - code?: string - title?: string - detail?: string - source?: { pointer?: string } - meta?: { error?: string } -} - /** * Turn a 422 response from the `_placeable` trigger into one error per reason. * @@ -32,7 +24,7 @@ interface ApiErrorObject { * They can only be told apart by their message. */ export function mapPlaceabilityErrors(error: unknown): PlaceabilityError[] { - const errors = extractErrorArray(error) + const errors = extractApiErrors(error) if (errors.length === 0) return [] return errors.map((apiError) => ({ @@ -42,25 +34,3 @@ export function mapPlaceabilityErrors(error: unknown): PlaceabilityError[] { ...(apiError.meta?.error != null ? { meta: { error: apiError.meta.error } } : {}), })) } - -/** - * `/data/attributes/payment_action` → `payment_action`. - * `/data` (a base error, not tied to an attribute) → `base`. - */ -function fieldFromPointer(pointer?: string): string | undefined { - if (pointer == null || pointer === "") return undefined - const last = pointer.split("/").pop() - if (last == null || last === "") return undefined - return last === "data" ? "base" : last -} - -/** - * The SDK surfaces API errors as a thrown object, but the exact wrapper has - * moved between versions, so probe the two shapes rather than assuming one. - */ -function extractErrorArray(error: unknown): ApiErrorObject[] { - if (error == null || typeof error !== "object") return [] - const candidate = error as { errors?: unknown; response?: { data?: { errors?: unknown } } } - const errors = candidate.errors ?? candidate.response?.data?.errors - return Array.isArray(errors) ? (errors as ApiErrorObject[]) : [] -} diff --git a/packages/react-components/specs/payment_settings/PaymentSettingGiftCard.spec.tsx b/packages/react-components/specs/payment_settings/PaymentSettingGiftCard.spec.tsx index 5825e55c..66d91087 100644 --- a/packages/react-components/specs/payment_settings/PaymentSettingGiftCard.spec.tsx +++ b/packages/react-components/specs/payment_settings/PaymentSettingGiftCard.spec.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { PaymentSetting } from "#components/payment_settings/PaymentSetting" import { PaymentSettingGiftCard } from "#components/payment_settings/PaymentSettingGiftCard" import { PaymentSettingGiftCardAddButton } from "#components/payment_settings/PaymentSettingGiftCardAddButton" +import { PaymentSettingGiftCardErrors } from "#components/payment_settings/PaymentSettingGiftCardErrors" import { PaymentSettingGiftCardInput } from "#components/payment_settings/PaymentSettingGiftCardInput" import { PaymentSettingGiftCardList } from "#components/payment_settings/PaymentSettingGiftCardList" import { PaymentSettingGiftCardListItem } from "#components/payment_settings/PaymentSettingGiftCardListItem" @@ -176,6 +177,97 @@ describe("PaymentSettingGiftCard", () => { expect(getOrder).not.toHaveBeenCalled() }) + // Without a component of its own the failure is invisible: these errors are + // rejected before anything is written to the order, so never sees them and the shopper gets a control that silently + // does nothing. + describe("errors", () => { + function renderWithErrors() { + return render( + + + + + + + + ) + } + + async function submit(code: string) { + await act(async () => { + fireEvent.change(screen.getByTestId("input"), { target: { value: code } }) + }) + await act(async () => { + fireEvent.click(screen.getByTestId("apply")) + }) + } + + it("renders nothing while there is nothing to report", () => { + renderWithErrors() + expect(screen.queryByTestId("gift-card-error")).toBeNull() + }) + + // The SDK throws with an empty `message`; the API's wording is only in the + // JSON:API errors array, so reading `message` renders an empty box. + it("shows the API message when a code is refused", async () => { + applyGiftCardMock.mockRejectedValue({ + errors: [ + { + title: "doesn't match any active gift card", + detail: "gift_card_code - doesn't match any active gift card", + code: "VALIDATION_ERROR", + source: { pointer: "/data/attributes/gift_card_code" }, + meta: { error: "invalid_gift_card" }, + }, + { + title: "can't be blank", + detail: "token - can't be blank", + code: "VALIDATION_ERROR", + source: { pointer: "/data/attributes/token" }, + }, + ], + }) + renderWithErrors() + + await submit("NOPE") + + await waitFor(() => { + expect(screen.getByTestId("gift-card-error").textContent).toBe( + "doesn't match any active gift card" + ) + }) + }) + + it("falls back to a message of its own when the failure never reached the API", async () => { + applyGiftCardMock.mockRejectedValue(new Error("Network request failed")) + renderWithErrors() + + await submit("NOPE") + + await waitFor(() => { + expect(screen.getByTestId("gift-card-error").textContent).toBe("Network request failed") + }) + }) + + it("clears the message once a later code is accepted", async () => { + applyGiftCardMock.mockRejectedValueOnce(new Error("Network request failed")) + renderWithErrors() + + await submit("NOPE") + await waitFor(() => { + expect(screen.queryByTestId("gift-card-error")).not.toBeNull() + }) + + applyGiftCardMock.mockResolvedValue({ id: "gift-new" }) + await submit("GOOD") + + await waitFor(() => { + expect(screen.queryByTestId("gift-card-error")).toBeNull() + }) + }) + }) + describe("the applied list", () => { it("renders one row per applied card, with code and amount", () => { renderGiftCards( diff --git a/packages/react-components/src/components/payment_settings/PaymentSetting.tsx b/packages/react-components/src/components/payment_settings/PaymentSetting.tsx index 922393b4..d03b1b10 100644 --- a/packages/react-components/src/components/payment_settings/PaymentSetting.tsx +++ b/packages/react-components/src/components/payment_settings/PaymentSetting.tsx @@ -65,7 +65,7 @@ interface Props { */ export function PaymentSetting({ children, onSelect, readonly }: Props): JSX.Element | null { const paymentsModel = usePaymentsModel() - const { isCovered } = usePaymentSessionsState() + const { isCovered, remainingAmountCents } = usePaymentSessionsState() const { order, include, includeLoaded, addResourceToInclude, getOrder } = useContext(OrderContext) const { accessToken, interceptors } = useContext(CommerceLayerContext) const [pendingSettingId, setPendingSettingId] = useState(null) @@ -135,6 +135,7 @@ export function PaymentSetting({ children, onSelect, readonly }: Props): JSX.Ele const reusable = findReusablePaymentSession({ paymentSessions: order.payment_sessions, paymentSettingId: setting.id, + amountCents: remainingAmountCents, }) if (reusable == null) { await createPaymentSession({ @@ -142,6 +143,9 @@ export function PaymentSetting({ children, onSelect, readonly }: Props): JSX.Ele interceptors, orderId: order.id, paymentSettingId: setting.id, + // The remainder after the gift cards, which the server cannot work + // out for itself until they are authorized at place time. + amountCents: remainingAmountCents, }) } const refreshed = await getOrder(order.id) diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCard.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCard.tsx index 2b423594..ec518d16 100644 --- a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCard.tsx +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCard.tsx @@ -1,4 +1,4 @@ -import { applyGiftCard, removeGiftCard } from "@commercelayer/core-components" +import { applyGiftCard, mapGiftCardErrors, removeGiftCard } from "@commercelayer/core-components" import { type JSX, type ReactNode, useCallback, useContext, useState } from "react" import CommerceLayerContext from "#context/CommerceLayerContext" import OrderContext from "#context/OrderContext" @@ -63,7 +63,7 @@ export function PaymentSettingGiftCard({ children, readonly }: Props): JSX.Eleme setInputRequested(false) await getOrder(order.id) } catch (error) { - setErrors([toGiftCardError(error)]) + setErrors(toGiftCardErrors(error)) } finally { setIsApplying(false) } @@ -79,7 +79,7 @@ export function PaymentSettingGiftCard({ children, readonly }: Props): JSX.Eleme await removeGiftCard({ accessToken, interceptors, order, paymentSessionId }) await getOrder(order.id) } catch (error) { - setErrors([toGiftCardError(error)]) + setErrors(toGiftCardErrors(error)) } }, [accessToken, interceptors, order, getOrder] @@ -114,17 +114,40 @@ export function PaymentSettingGiftCard({ children, readonly }: Props): JSX.Eleme /** * Both a code we recognise and the message the API sent. * + * The wording is dug out of the JSON:API `errors` array rather than read off + * `error.message`, which is empty on an SDK error — see `mapGiftCardErrors`, + * which also drops the `token - can't be blank` entry that rides along with + * every refusal. + * * The API collapses four different causes — no such code, expired, empty, bound * to another market — into one message, so we cannot tell the shopper *why*. * A translated consumer keys off `code`; one that wants the detail shows - * `message`. + * `message`. `meta.error` carries the API's symbolic reason when there is one. + * + * The fallback is for a failure that never reached the API at all — a dropped + * connection, say — where there is no `errors` array to read. */ -function toGiftCardError(error: unknown): BaseError { - const message = - error instanceof Error - ? error.message - : "This gift card code could not be applied to the order." - return { code: "INVALID_FIELD_VALUE", resource: "gift_cards", field: "gift_card_code", message } +function toGiftCardErrors(error: unknown): BaseError[] { + const mapped = mapGiftCardErrors(error).map((giftCardError) => ({ + code: "INVALID_FIELD_VALUE" as const, + resource: "gift_cards" as const, + field: giftCardError.field, + message: giftCardError.message, + ...(giftCardError.meta != null ? { meta: giftCardError.meta } : {}), + })) + if (mapped.length > 0) return mapped + + return [ + { + code: "INVALID_FIELD_VALUE", + resource: "gift_cards", + field: "gift_card_code", + message: + error instanceof Error && error.message !== "" + ? error.message + : "This gift card code could not be applied to the order.", + }, + ] } export default PaymentSettingGiftCard diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardErrors.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardErrors.tsx new file mode 100644 index 00000000..82ff67e4 --- /dev/null +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardErrors.tsx @@ -0,0 +1,57 @@ +import { type JSX, useContext } from "react" +import Parent from "#components/utils/Parent" +import PaymentSettingGiftCardContext from "#context/PaymentSettingGiftCardContext" +import type { BaseError } from "#typings/errors" +import type { ChildrenFunction } from "#typings/index" + +interface ChildrenProps extends Omit { + errors: BaseError[] +} + +interface Props extends Omit { + children?: ChildrenFunction +} + +/** + * Why applying or removing a gift card failed. + * + * Needed as a component of its own because these errors are **not** on the + * order: applying a card that does not exist is rejected before anything is + * written, so `` never sees them and the shopper gets + * a control that silently does nothing. Same reasoning as the `errors` render + * prop on ``, one family of errors up. + * + * The API collapses four causes — unknown code, expired, empty, bound to + * another market — into one message, so `code` is always + * `INVALID_FIELD_VALUE`. A translated consumer keys off that and writes its own + * text; one that wants the API's wording renders `message`. + * + * Renders nothing when there is nothing to report. + */ +export function PaymentSettingGiftCardErrors(props: Props): JSX.Element | null { + const { children, ...p } = props + const { errors } = useContext(PaymentSettingGiftCardContext) + const giftCardErrors = errors ?? [] + + if (giftCardErrors.length === 0) return null + + const parentProps = { ...props, errors: giftCardErrors } + + return children ? ( + {children} + ) : ( +
+ {giftCardErrors.map((error, index) => ( + + {error.message} + + ))} +
+ ) +} + +export default PaymentSettingGiftCardErrors diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts index e80dfcee..8a3a08c1 100644 --- a/packages/react-components/src/index.ts +++ b/packages/react-components/src/index.ts @@ -88,6 +88,7 @@ export * from "#components/payment_methods/PaymentMethodsContainer" export * from "#components/payment_settings/PaymentSetting" export * from "#components/payment_settings/PaymentSettingGiftCard" export * from "#components/payment_settings/PaymentSettingGiftCardAddButton" +export * from "#components/payment_settings/PaymentSettingGiftCardErrors" export * from "#components/payment_settings/PaymentSettingGiftCardInput" export * from "#components/payment_settings/PaymentSettingGiftCardList" export * from "#components/payment_settings/PaymentSettingGiftCardListItem" From 2899b1a2e43d1f4fe596866d4e5df98d44929bed Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:31:10 +0200 Subject: [PATCH 18/21] fix: improve disabling place button logic and add props to better style checkout page ui --- ...-18-place-order-split-by-payments-model.md | 9 +++ ...presentation-belongs-to-the-application.md | 72 +++++++++++++++++++ .../place-order-payments-model.spec.tsx | 71 ++++++++++++++++++ .../payment_settings/PaymentSetting.spec.tsx | 47 ++++++++++++ .../PaymentSettingGiftCard.spec.tsx | 50 +++++++------ .../PlaceOrderButtonPaymentSessions.tsx | 33 +++++++-- .../payment_settings/PaymentSetting.tsx | 61 ++++++++++++++-- .../PaymentSettingGiftCard.tsx | 59 +++++++++++---- .../PaymentSettingGiftCardAddButton.tsx | 44 ------------ .../PaymentSettingGiftCardInput.tsx | 10 +-- .../PaymentSettingGiftCardSubmitButton.tsx | 4 +- .../context/PaymentSettingGiftCardContext.ts | 4 -- packages/react-components/src/index.ts | 1 - 13 files changed, 364 insertions(+), 101 deletions(-) create mode 100644 docs/adr/2026-09-01-presentation-belongs-to-the-application.md delete mode 100644 packages/react-components/src/components/payment_settings/PaymentSettingGiftCardAddButton.tsx diff --git a/docs/adr/2026-08-18-place-order-split-by-payments-model.md b/docs/adr/2026-08-18-place-order-split-by-payments-model.md index 572a36bf..08e6389e 100644 --- a/docs/adr/2026-08-18-place-order-split-by-payments-model.md +++ b/docs/adr/2026-08-18-place-order-split-by-payments-model.md @@ -45,6 +45,15 @@ validation persists nothing, repeating the call is cheap and side-effect free. it appears only in an update response. It cannot be polled by refetching the order, and it cannot gate a button on render. +**What can gate the button on render is whether anything is paying for the order** — +`derivePaymentSessionsState` answers that from the order alone. Amended 2026-09-01: the +button originally had the privacy checkbox as its only gate, on the reasoning above, which +conflated two questions. Removing the gift card that was covering the remainder also +deletes the session paying the difference, leaving a live button whose only outcome was a +placeability failure. The gate is now `isCovered || total === 0 || currentPaymentSession`, +the same derivation the payment components use, so the button cannot disagree with the +selector above it. Placeability itself is still only knowable after the click. + **Coverage is enforced by a default payment rule, not by a hard-coded guard.** On the new model `validate_payments` is a no-op — both `validate_payment_method` and `validate_payment_source` return `true` when `new_payments?` diff --git a/docs/adr/2026-09-01-presentation-belongs-to-the-application.md b/docs/adr/2026-09-01-presentation-belongs-to-the-application.md new file mode 100644 index 00000000..73c58290 --- /dev/null +++ b/docs/adr/2026-09-01-presentation-belongs-to-the-application.md @@ -0,0 +1,72 @@ +# Presentation state belongs to the consuming application + +**Date:** 2026-09-01 +**Status:** accepted +**Scope:** the `payment_settings` components on the `payment_sessions` model + +## Context + +The first cut of `` held a piece of UI state: `inputRequested`, +from which it derived `isInputVisible = giftCardSessions.length === 0 || inputRequested`. +The input, the submit button and a `` were all gated on +it, and applying a card set it back to false so the field folded away on its own. + +Designing the checkout against these components exposed the cost. mfe-checkout wants the +whole gift card section behind a "use a gift card" switch, opening and closing on the +shopper's click. Two pieces of state then decide whether one input is on screen — the +application's switch and the library's flag — and the library's wins: mounting the input +while `isInputVisible` is false renders nothing, for no reason the application can see. + +The same gap showed up one component up. `` exposed the selection only +through ``'s render prop, so an application could style the +control but not the card around it, and could not make the whole card the click target. + +None of these components have shipped yet, so there is no compatibility to weigh. + +## Decision + +**The library owns domain rules; the application owns presentation.** + +Concretely, on the gift card side: + +- `isInputVisible` and `showInput` are gone from the context, along with + `` — a component whose only job was to flip that flag. + "Add another one" is now ordinary markup in the application. +- `` and `` render + whenever the **domain** allows it: not readonly, and `canAddGiftCard`. That rule stays + here because applying a card that is not needed fails with a 422 about `amount_cents` + that no shopper can act on. +- `` accepts a function child receiving the gift card state + (`giftCardSessions`, `canAddGiftCard`, `isCovered`, `remainingAmountCents`, + `giftCardAmountCents`, `isApplying`, `errors`, `readonly`). An application drives its own + disclosure off those — opening the section when a card is already applied, folding the + field away when the applied count goes up. + +And on the setting side: + +- `` accepts a function child receiving `{ setting, isSelected, isPending, + currentPaymentSession, errors, selectSetting }`, so the chosen option can be styled as a + card and the whole card can select it. +- `selectSetting` is guarded by a **ref**, not by `pendingSettingId`. A single click on a + card wrapping the radio reaches the handler twice, and both reads of the state variable + still say "idle" — which would leave two Payment Sessions behind for one click. + +The state components keep their own conditions, because those are domain rules too: +`` stays visible in readonly and when the order is covered, +and a charged card still renders no remove control. + +## Consequences + +**The field no longer folds away by itself after an apply.** An application that wants +that behaviour watches `giftCardSessions.length` through the function child. mfe-checkout +does exactly that, in `CheckoutPaymentSessions.tsx`. + +**One less component to document and version.** The add button was pure disclosure. + +**Function children are now the single way to read a subtree's state.** Both new render +props follow the shape already used across the library rather than introducing a hook, so +there is one idiom, not two. + +**Nothing prevents an application from rendering a control the domain would refuse** — it +can render its own button and call nothing. The components that actually talk to the API +still refuse, so the failure mode is a dead control, not a bad request. diff --git a/packages/react-components/specs/orders/place-order-payments-model.spec.tsx b/packages/react-components/specs/orders/place-order-payments-model.spec.tsx index c55dff21..7d3d7379 100644 --- a/packages/react-components/specs/orders/place-order-payments-model.spec.tsx +++ b/packages/react-components/specs/orders/place-order-payments-model.spec.tsx @@ -22,6 +22,7 @@ vi.mock("#components/orders/PlaceOrderButtonPaymentSource", () => ({ vi.mock("#utils/organization", () => ({ useOrganizationConfig: () => ({ urls: {} }) })) const MANUAL = { id: "ps-manual", type: "payment_setting_manuals" } +const GIFT_CARD = { id: "ps-gift", type: "payment_setting_gift_cards" } function orderOnSessions(overrides: Partial = {}): Partial { return { @@ -236,6 +237,76 @@ describe("PlaceOrderButtonPaymentSessions", () => { expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(false) }) }) + + // Removing a gift card deletes the session paying the difference along with + // it — its amount was fixed against the old remainder. Without this gate the + // shopper is left with a live button whose only possible outcome is a + // placeability failure. + describe("something has to be paying for the order", () => { + it("blocks the button when no session is left", () => { + renderButton( + orderOnSessions({ total_amount_with_taxes_cents: 7100, payment_sessions: [] } as never) + ) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true) + }) + + // A gift card is additive: on its own it pays part of the order, and the + // difference still needs a method. This is the same order state the bug + // report started from, one step earlier. + it("blocks the button when gift cards do not cover the order", () => { + renderButton( + orderOnSessions({ + total_amount_with_taxes_cents: 7100, + payment_sessions: [ + { + id: "session-gift", + status: "unpaid", + amount_cents: 2500, + payment_setting: GIFT_CARD, + }, + ], + } as never) + ) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true) + }) + + it("allows the button when gift cards cover the order outright", () => { + renderButton( + orderOnSessions({ + total_amount_with_taxes_cents: 7100, + payment_sessions: [ + { + id: "session-gift", + status: "unpaid", + amount_cents: 7100, + payment_setting: GIFT_CARD, + }, + ], + } as never) + ) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(false) + }) + + it("allows the button on a free order", () => { + renderButton( + orderOnSessions({ total_amount_with_taxes_cents: 0, payment_sessions: [] } as never) + ) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(false) + }) + + // An order fetched without the total in its `fields` reads as `undefined`, + // not as free. + it("blocks the button when the total is unknown and nothing is paying", () => { + renderButton(orderOnSessions({ payment_sessions: [] } as never)) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true) + }) + + // An explicit `disabled` stays the consumer's business either way. + it("leaves an explicit disabled prop in charge", () => { + renderButton(orderOnSessions({ payment_sessions: [] } as never), { disabled: false }) + expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(false) + }) + }) }) // The checkbox lives outside the button and talks to it through a DOM event. diff --git a/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx b/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx index 1c7741c4..6ed3861f 100644 --- a/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx +++ b/packages/react-components/specs/payment_settings/PaymentSetting.spec.tsx @@ -293,3 +293,50 @@ describe("PaymentSetting", () => { expect(warn).not.toHaveBeenCalled() }) }) + +// An application styling the chosen option, or making the whole card the click +// target, needs the selection outside the radio's own render prop. +describe("PaymentSetting children as a function", () => { + it("hands each setting its state", () => { + render( + + + {({ setting, isSelected, isPending }) => ( +
{`${setting.id}|${isSelected}|${isPending}`}
+ )} +
+
+ ) + expect(screen.getByTestId("card").textContent).toBe("ps-manual|false|false") + }) + + // One click reaching both a card and the radio inside it must not leave two + // Payment Sessions behind: `pendingSettingId` still reads as idle in the + // second handler, so the guard cannot be state. + it("ignores a second selection while one is in flight", async () => { + render( + + + {({ selectSetting }) => ( + - ) -} - -export default PaymentSettingGiftCardAddButton diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardInput.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardInput.tsx index 0608af47..18d3cbf0 100644 --- a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardInput.tsx +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardInput.tsx @@ -22,17 +22,17 @@ type Props = { * applying a card that is not needed fails with a 422 the shopper cannot make * sense of. * - * After the first card the input steps aside for - * ``, so a shopper who is done is not left - * looking at an empty field. + * Whether the field is on screen at all, on the other hand, is the + * application's business: a checkout that hides it behind a toggle simply does + * not mount this. Nothing here holds disclosure state that could fight that. */ export function PaymentSettingGiftCardInput(props: Props): JSX.Element | null { const { children, ...p } = props - const { canAddGiftCard, isInputVisible, isApplying, readonly, code, setCode } = useContext( + const { canAddGiftCard, isApplying, readonly, code, setCode } = useContext( PaymentSettingGiftCardContext ) - if (readonly === true || canAddGiftCard !== true || isInputVisible !== true) return null + if (readonly === true || canAddGiftCard !== true) return null const handleChange = (event: ChangeEvent): void => { setCode?.(event.target.value) diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardSubmitButton.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardSubmitButton.tsx index 1d168131..57b3a504 100644 --- a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardSubmitButton.tsx +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardSubmitButton.tsx @@ -22,11 +22,11 @@ type Props = { */ export function PaymentSettingGiftCardSubmitButton(props: Props): JSX.Element | null { const { children, label = "Apply", ...p } = props - const { canAddGiftCard, isInputVisible, isApplying, readonly, code, applyGiftCard } = useContext( + const { canAddGiftCard, isApplying, readonly, code, applyGiftCard } = useContext( PaymentSettingGiftCardContext ) - if (readonly === true || canAddGiftCard !== true || isInputVisible !== true) return null + if (readonly === true || canAddGiftCard !== true) return null const trimmed = (code ?? "").trim() const disabled = isApplying === true || trimmed === "" diff --git a/packages/react-components/src/context/PaymentSettingGiftCardContext.ts b/packages/react-components/src/context/PaymentSettingGiftCardContext.ts index 0e27f5ef..da4394f5 100644 --- a/packages/react-components/src/context/PaymentSettingGiftCardContext.ts +++ b/packages/react-components/src/context/PaymentSettingGiftCardContext.ts @@ -17,10 +17,6 @@ export interface InitialPaymentSettingGiftCardContext { * order is not implemented. */ canAddGiftCard?: boolean - /** Whether the shopper has asked for the input, or it is showing by default. */ - isInputVisible?: boolean - /** Show the input. Used by the "add another" control. */ - showInput?: () => void /** A code is being applied right now. */ isApplying?: boolean /** diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts index 8a3a08c1..9a1ec5a3 100644 --- a/packages/react-components/src/index.ts +++ b/packages/react-components/src/index.ts @@ -87,7 +87,6 @@ export * from "#components/payment_methods/PaymentMethodRadioButton" export * from "#components/payment_methods/PaymentMethodsContainer" export * from "#components/payment_settings/PaymentSetting" export * from "#components/payment_settings/PaymentSettingGiftCard" -export * from "#components/payment_settings/PaymentSettingGiftCardAddButton" export * from "#components/payment_settings/PaymentSettingGiftCardErrors" export * from "#components/payment_settings/PaymentSettingGiftCardInput" export * from "#components/payment_settings/PaymentSettingGiftCardList" From 5545d109a4d524c64fc70e51a74364323bcc252c Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:34:05 +0200 Subject: [PATCH 19/21] fix: put the payment_sessions button on the terms acceptance store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase landed on a main that had replaced the privacy & terms channel: `PLACE_ORDER_RECHECK_EVENT` and the `privacy-terms` localStorage key are both gone, and acceptance now lives in the in-memory `termsAcceptanceStore`, keyed by order and deliberately not persisted. `PlaceOrderButtonPaymentSessions` read the removed key behind the removed event, so the gate could never open. It now subscribes through `useTermsAndConditions()`, which is the supported channel and the same one `placeOrderPermitted` reads — so the two branches cannot disagree. The additive DOM event this branch had added to `` is no longer needed and goes with it. `terms-acceptance.spec.tsx` arrived from main with an order carrying neither available_payment_methods nor available_payment_settings. With `` now routing on the Payments Model that order is undetermined and gets the inert button, so most of the suite was passing for the wrong reason. The fixture now names its payment methods. Also two lint errors that only surface with main's biome: the `paymentsModel` guard added to PaymentMethod's effects was missing from their dependency lists, and PaymentSettingGiftCardErrors keyed its spans by array index. Co-Authored-By: Claude Opus 5 (1M context) --- .../place-order-payments-model.spec.tsx | 19 +++++---- .../specs/orders/place-order.spec.tsx | 41 +------------------ .../specs/orders/terms-acceptance.spec.tsx | 5 +++ .../PlaceOrderButtonPaymentSessions.tsx | 26 +++--------- .../orders/PrivacyAndTermsCheckbox.tsx | 6 --- .../payment_methods/PaymentMethod.tsx | 2 + .../PaymentSettingGiftCardErrors.tsx | 8 +--- 7 files changed, 27 insertions(+), 80 deletions(-) diff --git a/packages/react-components/specs/orders/place-order-payments-model.spec.tsx b/packages/react-components/specs/orders/place-order-payments-model.spec.tsx index 7d3d7379..16c45863 100644 --- a/packages/react-components/specs/orders/place-order-payments-model.spec.tsx +++ b/packages/react-components/specs/orders/place-order-payments-model.spec.tsx @@ -6,6 +6,10 @@ import { PlaceOrderButton } from "#components/orders/PlaceOrderButton" import { PlaceOrderButtonPaymentSessions } from "#components/orders/PlaceOrderButtonPaymentSessions" import CommerceLayerContext from "#context/CommerceLayerContext" import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import { + resetTermsAcceptanceStore, + setAccepted as setTermsAccepted, +} from "#utils/termsAcceptanceStore" const { placeOrderMock } = vi.hoisted(() => ({ placeOrderMock: vi.fn() })) @@ -65,6 +69,7 @@ function Wrapper({ beforeEach(() => { vi.clearAllMocks() localStorage.clear() + resetTermsAcceptanceStore() placeOrderMock.mockResolvedValue({ placed: true, order: { status: "placed" }, errors: [] }) }) @@ -222,7 +227,7 @@ describe("PlaceOrderButtonPaymentSessions", () => { }) it("allows the button once the box is checked", () => { - localStorage.setItem("privacy-terms", "true") + setTermsAccepted("order-1", true) renderButton( orderOnSessions({ privacy_url: "https://example.com/privacy", @@ -309,11 +314,12 @@ describe("PlaceOrderButtonPaymentSessions", () => { }) }) -// The checkbox lives outside the button and talks to it through a DOM event. -// In container mode it used to notify only PlaceOrderContext, which the newer -// branch does not use — leaving the button disabled forever. +// The checkbox is a *sibling* of the button, never its ancestor, so no provider +// can sit above both: the acceptance store is the shared channel. Subscribing to +// it is what keeps a `payment_sessions` button — which does not use +// PlaceOrderContext — from staying disabled forever. describe("privacy checkbox reaches the payment_sessions button", () => { - it("enables the button when the recheck event fires", async () => { + it("enables the button when acceptance lands in the store", async () => { render( { expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true) await act(async () => { - localStorage.setItem("privacy-terms", "true") - window.dispatchEvent(new CustomEvent("cl:placeorder:recheck")) + setTermsAccepted("order-1", true) }) await waitFor(() => { diff --git a/packages/react-components/specs/orders/place-order.spec.tsx b/packages/react-components/specs/orders/place-order.spec.tsx index c56bc750..b403e3e4 100644 --- a/packages/react-components/specs/orders/place-order.spec.tsx +++ b/packages/react-components/specs/orders/place-order.spec.tsx @@ -9,7 +9,7 @@ import CustomerContext from "#context/CustomerContext" import OrderContext, { defaultOrderContext } from "#context/OrderContext" import PaymentMethodContext, { defaultPaymentMethodContext } from "#context/PaymentMethodContext" import PlaceOrderContext, { defaultPlaceOrderContext } from "#context/PlaceOrderContext" -import { PLACE_ORDER_RECHECK_EVENT, usePlaceOrder } from "#hooks/usePlaceOrder" +import { usePlaceOrder } from "#hooks/usePlaceOrder" import { getAcceptedSnapshot, getCheckboxCount, @@ -799,45 +799,6 @@ describe("PrivacyAndTermsCheckbox (container mode)", () => { vi.mocked(getCardDetails).mockReturnValue({ brand: "" } as never) }) - // The event is dispatched in both modes. It is the only channel that reaches - // a `payment_sessions` place-order button, which does not subscribe to the - // acceptance store — and a consumer may still mount the deprecated - // above it, as mfe-checkout does. - it("dispatches PLACE_ORDER_RECHECK_EVENT in container mode too", async () => { - const handler = vi.fn() - window.addEventListener(PLACE_ORDER_RECHECK_EVENT, handler) - - render( - - - - - - ) - - await waitFor(() => { - expect(screen.getByRole("checkbox").getAttribute("disabled")).toBeNull() - }) - - await act(async () => { - fireEvent.click(screen.getByRole("checkbox")) - }) - - expect(handler).toHaveBeenCalled() - window.removeEventListener(PLACE_ORDER_RECHECK_EVENT, handler) - }) }) // --------------------------------------------------------------------------- diff --git a/packages/react-components/specs/orders/terms-acceptance.spec.tsx b/packages/react-components/specs/orders/terms-acceptance.spec.tsx index 14133bee..811b89d5 100644 --- a/packages/react-components/specs/orders/terms-acceptance.spec.tsx +++ b/packages/react-components/specs/orders/terms-acceptance.spec.tsx @@ -59,6 +59,11 @@ const ORDER: any = { id: "order-1", status: "pending", total_amount_with_taxes_cents: 1000, + // `` routes on the Payments Model, and an order with + // neither `available_payment_methods` nor `available_payment_settings` is + // undetermined — it would get the inert button and every assertion here + // would pass for the wrong reason. + available_payment_methods: [{ id: "pm-1", payment_source_type: "stripe_payments" }], payment_method: { id: "pm-1", payment_source_type: "stripe_payments" }, payment_source: { id: "ps-1", type: "stripe_payments" }, billing_address: { id: "ba-1" }, diff --git a/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx index 99a13138..4f2eadc0 100644 --- a/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx +++ b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx @@ -4,12 +4,12 @@ import { placeOrderWithPaymentSessions, } from "@commercelayer/core-components" import type { Order } from "@commercelayer/sdk" -import { type JSX, type MouseEvent, type ReactNode, useContext, useEffect, useState } from "react" +import { type JSX, type MouseEvent, type ReactNode, useContext, useState } from "react" import Parent from "#components/utils/Parent" import CommerceLayerContext from "#context/CommerceLayerContext" import OrderContext from "#context/OrderContext" import { usePaymentSessionsState } from "#hooks/usePaymentSessionsState" -import { PLACE_ORDER_RECHECK_EVENT } from "#hooks/usePlaceOrder" +import { useTermsAndConditions } from "#hooks/useTermsAndConditions" import type { BaseError } from "#typings/errors" import type { ChildrenFunction } from "#typings/index" import { useOrganizationConfig } from "#utils/organization" @@ -72,27 +72,11 @@ export function PlaceOrderButtonPaymentSessions(props: Props): JSX.Element { const { accessToken, interceptors } = useContext(CommerceLayerContext) const [isLoading, setIsLoading] = useState(false) const organizationConfig = useOrganizationConfig({ accessToken }) - const [privacyTermsChecked, setPrivacyTermsChecked] = useState( - () => localStorage.getItem("privacy-terms") === "true" - ) - // The privacy and terms gate is a legal requirement of the checkout, not a // property of the payment model, so it applies here exactly as it does to the - // older branch — same storage key, same "only when both URLs are configured" - // rule as `placeOrderPermitted`. - // - // `` announces changes through a DOM event rather - // than context, so there is nothing to read: we listen for the same event the - // older branch listens for. - useEffect(() => { - const recheck = (): void => { - setPrivacyTermsChecked(localStorage.getItem("privacy-terms") === "true") - } - window.addEventListener(PLACE_ORDER_RECHECK_EVENT, recheck) - return () => { - window.removeEventListener(PLACE_ORDER_RECHECK_EVENT, recheck) - } - }, []) + // older branch — same acceptance store, same "only when both URLs are + // configured" rule as `placeOrderPermitted`. + const { accepted: privacyTermsChecked } = useTermsAndConditions() const privacyUrl = order?.privacy_url ?? organizationConfig?.urls?.privacy const termsUrl = order?.terms_url ?? organizationConfig?.urls?.terms diff --git a/packages/react-components/src/components/orders/PrivacyAndTermsCheckbox.tsx b/packages/react-components/src/components/orders/PrivacyAndTermsCheckbox.tsx index 8c8c7a7b..c3d7bd90 100644 --- a/packages/react-components/src/components/orders/PrivacyAndTermsCheckbox.tsx +++ b/packages/react-components/src/components/orders/PrivacyAndTermsCheckbox.tsx @@ -1,7 +1,6 @@ import { type JSX, useContext, useEffect, useState } from "react" import CommerceLayerContext from "#context/CommerceLayerContext" import OrderContext from "#context/OrderContext" -import { PLACE_ORDER_RECHECK_EVENT } from "#hooks/usePlaceOrder" import { useTermsAndConditions } from "#hooks/useTermsAndConditions" import { useOrganizationConfig } from "#utils/organization" import { registerCheckbox } from "#utils/termsAcceptanceStore" @@ -20,11 +19,6 @@ export function PrivacyAndTermsCheckbox(props: Partial): JSX.Ele const handleChange = (e: React.ChangeEvent): void => { setAccepted((e.target as HTMLInputElement)?.checked) - // Announce the change through a DOM event too. The acceptance store above - // is subscribed to by the `payment_source` branch only, so a - // `payment_sessions` place-order button would never hear the checkbox and - // would stay disabled for good. The event is additive. - window.dispatchEvent(new CustomEvent(PLACE_ORDER_RECHECK_EVENT)) } useEffect(() => { diff --git a/packages/react-components/src/components/payment_methods/PaymentMethod.tsx b/packages/react-components/src/components/payment_methods/PaymentMethod.tsx index 2e0feab7..b0225906 100644 --- a/packages/react-components/src/components/payment_methods/PaymentMethod.tsx +++ b/packages/react-components/src/components/payment_methods/PaymentMethod.tsx @@ -171,6 +171,7 @@ export function PaymentMethod({ onClick, paymentSource, showLoader, + paymentsModel, ]) useEffect(() => { // Silencing this component in render is not enough: React runs a mounted @@ -265,6 +266,7 @@ export function PaymentMethod({ paymentSource, showLoader, autoSelectSinglePaymentMethod, + paymentsModel, ]) useEffect(() => { if (paymentMethods) { diff --git a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardErrors.tsx b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardErrors.tsx index 82ff67e4..6d6408bb 100644 --- a/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardErrors.tsx +++ b/packages/react-components/src/components/payment_settings/PaymentSettingGiftCardErrors.tsx @@ -41,12 +41,8 @@ export function PaymentSettingGiftCardErrors(props: Props): JSX.Element | null { {children} ) : (
- {giftCardErrors.map((error, index) => ( - + {giftCardErrors.map((error) => ( + {error.message} ))} From e3762a2b30f2d3b784cebcc8448c551b3675eb8f Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:53:59 +0200 Subject: [PATCH 20/21] fix: settle the authorizations before checking placeability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every place attempt used to open with a `_placeable` that was guaranteed to fail. Authorizing runs in a background job, so the check ran while the money was still moving and the API answered 422 "the payment doesn't cover the minimum required percentage" — three runs out of three against a real order. The retry recovered, so the only visible trace was a console error, but the budget was really four attempts out of five and a slower API would have reported a coverage failure on a payment that was about to succeed. Each attempt now reads the order back before touching `_placeable`. The authorization states are what says whether a refusal is worth waiting on, and `_placeable` cannot see them. That turns the wasted attempt into a cheap GET and lets the loop end as soon as an authorization has actually failed, instead of spending the whole budget waiting for a verdict that already arrived. The cost is flat in the number of sessions, since every state arrives in the same GET — which a per-session wait could not promise. Defaults move to 8 attempts of 500ms accordingly. The last attempt asks anyway: `_placeable`'s refusal is the only message the API gives us, and a place that fails with nothing on screen is worse than one that reports the coverage rule. `requires_action` is deliberately not treated as in flight — it waits for the shopper, not the server, so polling it would spend the budget on a flow this iteration does not implement. Note the failure check is scoped to the sessions paying for this order. A session burnt on an earlier attempt stays on the order after the shopper re-selects, and reading it as this attempt's verdict would end the loop before the authorization just created had a chance to settle. Two races go with it: - `_place` refused on an order `auto_place` placed in the window after the placeability check passed. Being a state error rather than a payment rule, it did not map to a placeability error and was rethrown as a hard failure on an order that was paid for and placed. It now re-reads the order and treats a placed one as the success it is. - the place button refetched the order after a reported error but not after a thrown one. Authorizations may already have been created when it threw, and the stale order still showed their sessions without one — which reads as "nothing has been charged yet", so a second click authorized again and took the money twice. Verified against real orders on the new-payments-demo organization: wire transfer alone, and a 10 USD gift card plus wire transfer for the difference. Both place with a single `_placeable`, no 422, and payment sessions summing exactly to `total_amount_with_taxes_cents`. Co-Authored-By: Claude Opus 5 (1M context) --- .../placeOrderWithPaymentSessions.spec.ts | 164 +++++++++++++++++- .../placeOrderWithPaymentSessions.ts | 136 +++++++++++++-- .../src/payment_sessions/types.ts | 50 ++++++ .../place-order-payments-model.spec.tsx | 17 ++ .../PlaceOrderButtonPaymentSessions.tsx | 13 ++ 5 files changed, 361 insertions(+), 19 deletions(-) diff --git a/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.spec.ts b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.spec.ts index b6a124c5..e011e27c 100644 --- a/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.spec.ts +++ b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.spec.ts @@ -50,25 +50,42 @@ function refusal(detail = "The payment doesn't cover the order.") { } interface SdkStub { + retrieve: ReturnType _placeable: ReturnType _place: ReturnType createAuthorization: ReturnType } -function stubSdk(): SdkStub { +/** + * The order the placeability loop reads at the top of every attempt. Defaults + * to the same sessions the call was given, with no authorizations on them — + * i.e. nothing in flight, so the loop goes straight to `_placeable`. + */ +function stubSdk(retrieved: Order = order([], "pending")): SdkStub { const stub: SdkStub = { + retrieve: vi.fn().mockResolvedValue(retrieved), _placeable: vi.fn().mockResolvedValue(order([], "pending")), _place: vi.fn().mockResolvedValue(order([], "placed")), createAuthorization: vi.fn().mockResolvedValue({ id: "auth-1" }), } getSdkMock.mockReturnValue({ - orders: { _placeable: stub._placeable, _place: stub._place, relationship: vi.fn() }, + orders: { + retrieve: stub.retrieve, + _placeable: stub._placeable, + _place: stub._place, + relationship: vi.fn(), + }, payment_authorizations: { create: stub.createAuthorization }, payment_sessions: { relationship: vi.fn((id: string) => ({ id, type: "payment_sessions" })) }, }) return stub } +/** A session carrying an authorization in `status`. */ +function authorized(status: string, overrides: Partial = {}): PaymentSession { + return session({ payment_authorization: { status } as never, ...overrides }) +} + /** Keeps the retry loop synchronous. The delay is asserted separately. */ const NO_WAIT = { attempts: 5, intervalMs: 0 } @@ -309,4 +326,147 @@ describe("placeOrderWithPaymentSessions", () => { expect(result.placed).toBe(true) }) }) + + describe("waiting for the authorizations to settle", () => { + // Asking `_placeable` while the money is still moving produces a 422 that + // means nothing: it says the order is not covered because the payment has + // not landed yet, not because it will not. + it("does not ask _placeable while an authorization is in flight", async () => { + const sdk = stubSdk() + sdk.retrieve + .mockResolvedValueOnce(order([authorized("pending")])) + .mockResolvedValueOnce(order([authorized("processing")])) + .mockResolvedValue(order([authorized("succeeded")])) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + ...NO_WAIT, + }) + + expect(sdk.retrieve).toHaveBeenCalledTimes(3) + expect(sdk._placeable).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ placed: true, errors: [], timedOut: false }) + }) + + // `requires_action` waits for the shopper, not for the server, so polling + // it would spend the budget on something no amount of waiting resolves. + it("does not wait on an authorization that requires shopper action", async () => { + const sdk = stubSdk(order([authorized("requires_action")])) + + await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + ...NO_WAIT, + }) + + expect(sdk._placeable).toHaveBeenCalledTimes(1) + }) + + // Its refusal is the only message we will have to show, so the budget + // running out must not mean returning nothing. + it("asks _placeable on the last attempt even with an authorization in flight", async () => { + const sdk = stubSdk(order([authorized("pending")])) + sdk._placeable.mockRejectedValue(refusal("Still not covered.")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + attempts: 3, + intervalMs: 0, + }) + + expect(sdk._placeable).toHaveBeenCalledTimes(1) + expect(result.timedOut).toBe(true) + expect(result.errors[0]?.message).toBe("Still not covered.") + }) + + // A settled failure is a verdict. Retrying would only delay the same + // answer, and `timedOut` would misreport it as a payment still in progress. + it("reports a failed authorization at once, without timing out", async () => { + const sdk = stubSdk(order([authorized("declined")])) + sdk._placeable.mockRejectedValue(refusal("The payment doesn't cover the order.")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + ...NO_WAIT, + }) + + expect(sdk._placeable).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ placed: false, timedOut: false }) + expect(result.errors[0]?.message).toBe("The payment doesn't cover the order.") + }) + + // A burnt session stays on the order after the shopper re-selects. Reading + // it as this attempt's verdict would end the loop before the authorization + // just created has had a chance to settle. + it("ignores a failed authorization on a session that is not paying", async () => { + const sdk = stubSdk() + const burnt = session({ id: "burnt", payment_authorization: { status: "declined" } as never }) + sdk.retrieve + .mockResolvedValueOnce(order([burnt, authorized("pending")])) + .mockResolvedValue(order([burnt, authorized("succeeded")])) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([burnt, session()]), + ...NO_WAIT, + }) + + expect(sdk._placeable).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ placed: true, timedOut: false }) + }) + + it("recognises an order auto_place already placed, before asking _placeable", async () => { + const sdk = stubSdk(order([], "placed")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + ...NO_WAIT, + }) + + expect(sdk._placeable).not.toHaveBeenCalled() + expect(sdk._place).not.toHaveBeenCalled() + expect(result).toMatchObject({ placed: true, timedOut: false }) + }) + }) + + describe("the window between _placeable and _place", () => { + // auto_place can fire in that window. `_place` then refuses an order that + // was placed successfully, and reporting the refusal would tell the shopper + // their payment failed on an order that is paid for. + it("treats a _place refusal on an already placed order as success", async () => { + const sdk = stubSdk() + sdk.retrieve + .mockResolvedValueOnce(order([], "pending")) + .mockResolvedValue(order([], "placed")) + sdk._place.mockRejectedValue(new Error("Cannot place a placed order.")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + ...NO_WAIT, + }) + + expect(result).toMatchObject({ placed: true, errors: [], timedOut: false }) + }) + + it("still reports a _place refusal when the order really is not placed", async () => { + const sdk = stubSdk() + sdk._place.mockRejectedValue(refusal("The payment doesn't cover the order.")) + + const result = await placeOrderWithPaymentSessions({ + accessToken: ACCESS_TOKEN, + order: order([session()]), + attempts: 2, + intervalMs: 0, + }) + + expect(sdk._place).toHaveBeenCalledTimes(2) + expect(result).toMatchObject({ placed: false, timedOut: true }) + expect(result.errors[0]?.message).toBe("The payment doesn't cover the order.") + }) + }) }) diff --git a/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts index 6630ca8d..25e92910 100644 --- a/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts +++ b/packages/core-components/src/payment_sessions/placeOrderWithPaymentSessions.ts @@ -3,12 +3,27 @@ import { getSdk } from "#sdk" import type { RequestConfig } from "#types" import { derivePaymentSessionsState } from "./derivePaymentSessionsState" import { mapPlaceabilityErrors } from "./mapPlaceabilityErrors" -import { hasLiveAuthorization, type PlaceabilityError } from "./types" +import { + hasAuthorizationInFlight, + hasFailedAuthorization, + hasLiveAuthorization, + type PlaceabilityError, +} from "./types" -/** Attempts before the placeability check gives up. */ -export const DEFAULT_PLACEABLE_ATTEMPTS = 5 +/** + * Attempts before the placeability check gives up. + * + * Each attempt starts by reading the order back, so an attempt spent waiting + * for an authorization costs one `GET` rather than a refused `PATCH`. That + * makes attempts cheap enough to run more of them, more closely spaced, than + * when every one of them was a `_placeable` call. + */ +export const DEFAULT_PLACEABLE_ATTEMPTS = 8 /** Delay between placeability attempts, in milliseconds. */ -export const DEFAULT_PLACEABLE_INTERVAL_MS = 1000 +export const DEFAULT_PLACEABLE_INTERVAL_MS = 500 + +/** Order includes the placeability loop needs to read authorization states. */ +const AUTHORIZATION_INCLUDES = ["payment_sessions.payment_authorization"] interface PlaceOrderWithPaymentSessionsParams extends Pick { @@ -62,13 +77,41 @@ export interface PlaceOrderWithPaymentSessionsResult { * point. * * **Why `_placeable` is retried instead of reported.** Authorizing is - * asynchronous — it runs in a background job — so the first check legitimately - * fails with "the payment doesn't cover the required percentage" while the - * money is still being taken. Reporting that straight away would tell the - * shopper their payment failed a second before it succeeded. Only an error that + * asynchronous — it runs in a background job — so a check taken while the money + * is still being taken legitimately fails with "the payment doesn't cover the + * required percentage". Reporting that straight away would tell the shopper + * their payment failed a second before it succeeded. Only an error that * survives the last attempt is real. A failed `_placeable` persists nothing, so * retrying is side-effect free despite the PATCH verb. * + * **Why each attempt reads the order first.** The authorization states are the + * only thing that says whether a refusal is worth waiting on, and `_placeable` + * cannot see them — so asking it while an authorization is in flight produces a + * guaranteed 422 that means nothing. Reading the order first turns that wasted + * attempt into a cheap `GET` and buys three things: + * + * - an authorization that has already **failed** ends the loop at once, instead + * of spending the whole budget waiting for a verdict that has arrived; + * - an order `auto_place` has already placed is recognised wherever in the + * sequence it happens, not only at the point the old code looked; + * - the cost is flat in the number of sessions. Every authorization state + * arrives in the same `GET`, so orders paying with several gift cards poll no + * harder than one paying with a single method — which is what a per-session + * wait, concurrent or not, could not promise. + * + * **The last attempt always asks.** When the budget runs out with an + * authorization still in flight, `_placeable` is called anyway: its refusal is + * the only message the API will give us, and returning none would leave the + * shopper with a failed place and nothing on screen. + * + * **A failed authorization still gets its message from `_placeable`.** A + * Payment Authorization carries no error attribute — only `status`, balances + * and the gateway's raw `response_data` — so there is nothing better to report, + * and inventing copy here would put payment wording in a package that has no + * business owning it. What changes is the timing: the refusal is returned as + * soon as the authorization settles, with `timedOut: false`, because a settled + * failure is a real answer rather than latency. + * * **There is no client-side coverage gate.** Coverage is enforced by a payment * rule whose threshold an organization can change — it can be lowered to accept * part payments — so only the server knows what "covered" means for this order. @@ -89,11 +132,20 @@ export async function placeOrderWithPaymentSessions({ const sdk = getSdk({ accessToken, interceptors }) const state = derivePaymentSessionsState(order) + // The sessions paying for this order, by the same derivation the rest of the + // payment UI uses. Kept as a set because the placeability loop has to tell + // them apart from sessions merely sitting on the order: an authorization that + // failed on an earlier attempt stays there, and reading it as this attempt's + // verdict would end the loop before the authorization just created has had a + // chance to settle. + const payingSessions = [...state.giftCardSessions, state.currentPaymentSession].filter( + (session): session is PaymentSession => session != null + ) + const payingSessionIds = new Set(payingSessions.map((session) => session.id)) + // Gift cards first, then the difference. Sequential, not concurrent: each // authorization changes what the next one is allowed to take. - const toAuthorize = [...state.giftCardSessions, state.currentPaymentSession] - .filter((session): session is PaymentSession => session != null) - .filter(needsAuthorization) + const toAuthorize = payingSessions.filter(needsAuthorization) for (const session of toAuthorize) { try { @@ -112,25 +164,48 @@ export async function placeOrderWithPaymentSessions({ let lastErrors: PlaceabilityError[] = [] for (let attempt = 1; attempt <= attempts; attempt++) { + const isLastAttempt = attempt === attempts + const current = await sdk.orders.retrieve(order.id, { include: AUTHORIZATION_INCLUDES }) + + // `auto_place` on the Payment Setting places the order inside the + // authorization job, so it can be placed before we ever ask to place it. + if (current.status === "placed") { + return { placed: true, order: current, errors: [], timedOut: false } + } + + const sessions = (current.payment_sessions ?? []).filter((session) => + payingSessionIds.has(session.id) + ) + const failed = sessions.some(hasFailedAuthorization) + + // Nothing `_placeable` says while the money is still moving is worth + // reporting, so do not spend a PATCH asking — unless this is the last + // attempt, whose refusal is the only message we will have to show. + if (!failed && !isLastAttempt && sessions.some(hasAuthorizationInFlight)) { + await sleep(intervalMs) + continue + } + try { const checked = await sdk.orders._placeable(order.id) - // `auto_place` on the Payment Setting places the order inside the - // authorization job, so it can already be placed by the time we look. // `_placeable` returns 200 for a placed order — `ensure_pending` only - // promotes drafts, it does not reject anything else — so this is a - // success, not a race to recover from. + // promotes drafts, it does not reject anything else — so a placed order + // here is a success, not a race to recover from. if (checked.status === "placed") { return { placed: true, order: checked, errors: [], timedOut: false } } - const placed = await sdk.orders._place(order.id) - return { placed: placed.status === "placed", order: placed, errors: [], timedOut: false } + return await placeOrRecover(sdk, order.id) } catch (error) { lastErrors = mapPlaceabilityErrors(error) // An error we cannot read as a placeability refusal is not something // waiting will fix — surface it instead of burning the attempts. if (lastErrors.length === 0) throw error + // A failed authorization is a verdict, not latency. Retrying would only + // delay the same answer, and `timedOut` would misreport a settled + // refusal as a payment that might still succeed. + if (failed) return { placed: false, errors: lastErrors, timedOut: false } if (attempt < attempts) await sleep(intervalMs) } } @@ -138,6 +213,33 @@ export async function placeOrderWithPaymentSessions({ return { placed: false, errors: lastErrors, timedOut: true } } +/** + * `_place` the order, treating "already placed" as the success it is. + * + * `auto_place` can fire in the window between the placeability check passing + * and this call. `_place` then refuses an order that was placed successfully, + * and — being a state error rather than a payment rule — the refusal does not + * map to a placeability error, so it would be rethrown as a hard failure on an + * order that is paid for and placed. Reading the order back is the only way to + * tell that apart from a genuine refusal. + */ +async function placeOrRecover( + sdk: ReturnType, + orderId: string +): Promise { + try { + const placed = await sdk.orders._place(orderId) + return { placed: placed.status === "placed", order: placed, errors: [], timedOut: false } + } catch (error) { + const current = await sdk.orders.retrieve(orderId).catch(() => undefined) + if (current?.status === "placed") { + return { placed: true, order: current, errors: [], timedOut: false } + } + // Not placed after all: let the caller's loop map and report it as before. + throw error + } +} + /** * Skip the authorization when the session already has one that has not failed. * A `pending` or `processing` authorization is still in flight, and creating a diff --git a/packages/core-components/src/payment_sessions/types.ts b/packages/core-components/src/payment_sessions/types.ts index e1418376..a8b0d861 100644 --- a/packages/core-components/src/payment_sessions/types.ts +++ b/packages/core-components/src/payment_sessions/types.ts @@ -70,6 +70,24 @@ export const TERMINAL_FAILURE_TRANSACTION_STATUSES = [ "expired", ] as const satisfies readonly KnownPaymentTransactionStatus[] +/** + * Transaction states the API will leave on its own, without anyone acting. + * Authorizing runs in a background job, so an authorization sits here between + * the `POST` that creates it and the money actually being taken. + * + * `requires_action` is deliberately **not** here. It is waiting for the + * *shopper* — a 3DS challenge or an equivalent redirect — so no amount of + * polling resolves it, and treating it as in flight would spend the whole + * retry budget on something that needs a flow this iteration does not + * implement. It is not a failure either (see + * `TERMINAL_FAILURE_TRANSACTION_STATUSES`), so it falls through to the + * placeability check and is reported as the API describes it. + */ +export const IN_FLIGHT_TRANSACTION_STATUSES = [ + "pending", + "processing", +] as const satisfies readonly KnownPaymentTransactionStatus[] + /** * One reason the API gave for refusing to place an order, mapped out of a 422 * JSON:API error object. @@ -121,3 +139,35 @@ export function hasLiveAuthorization(session: PaymentSession): boolean { status as (typeof TERMINAL_FAILURE_TRANSACTION_STATUSES)[number] ) } + +/** + * True when the session's authorization is still being worked on server-side. + * + * This is the state the placeability check cannot see through: the money is + * neither taken nor refused, so a refusal read while this holds says nothing + * about whether the payment will succeed. + */ +export function hasAuthorizationInFlight(session: PaymentSession): boolean { + const status = session.payment_authorization?.status + if (status == null) return false + return IN_FLIGHT_TRANSACTION_STATUSES.includes( + status as (typeof IN_FLIGHT_TRANSACTION_STATUSES)[number] + ) +} + +/** + * True when the session's authorization reached a state it cannot leave. + * + * Note the API exposes no reason for it: a Payment Authorization carries only + * `status`, the balances and the gateway's raw `response_data` + * (`config/attributes/payment_authorization.yml` in `core-api`). So this + * answers *whether* waiting is pointless, never *why* the payment failed — + * the message still has to come from the API's own refusal. + */ +export function hasFailedAuthorization(session: PaymentSession): boolean { + const status = session.payment_authorization?.status + if (status == null) return false + return TERMINAL_FAILURE_TRANSACTION_STATUSES.includes( + status as (typeof TERMINAL_FAILURE_TRANSACTION_STATUSES)[number] + ) +} diff --git a/packages/react-components/specs/orders/place-order-payments-model.spec.tsx b/packages/react-components/specs/orders/place-order-payments-model.spec.tsx index 16c45863..0d6172f2 100644 --- a/packages/react-components/specs/orders/place-order-payments-model.spec.tsx +++ b/packages/react-components/specs/orders/place-order-payments-model.spec.tsx @@ -213,6 +213,23 @@ describe("PlaceOrderButtonPaymentSessions", () => { }) }) + // Authorizations may already have been created when the place threw, and the + // order in context still shows their sessions without one — which reads as + // "nothing has been charged yet". A shopper clicking again on that stale + // order would be authorized a second time and charged twice. + it("refetches the order after a thrown error, not only after a reported one", async () => { + placeOrderMock.mockRejectedValue(new Error("Unauthorized")) + renderButton(orderOnSessions()) + + await act(async () => { + fireEvent.click(screen.getByRole("button")) + }) + + await waitFor(() => { + expect(getOrder).toHaveBeenCalledWith("order-1") + }) + }) + describe("privacy and terms", () => { // A legal requirement of the checkout, not a property of the payment model, // so it gates this branch exactly as it gates the older one. diff --git a/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx index 4f2eadc0..c7f29710 100644 --- a/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx +++ b/packages/react-components/src/components/orders/PlaceOrderButtonPaymentSessions.tsx @@ -140,6 +140,19 @@ export function PlaceOrderButtonPaymentSessions(props: Props): JSX.Element { ] setOrderErrors(errors) onClick?.({ placed: false, errors }) + // Refetch here too, and not only on the reported-error path above. + // Authorizations may well have been created before this threw, and the + // order in context still shows their sessions without one — which reads + // as "nothing has been charged yet". A shopper who clicks again on that + // stale order gets a second authorization over the first, and the money + // taken twice. Pulling the order back makes the existing + // `hasLiveAuthorization` guard see what actually happened. + try { + await getOrder(order.id) + } catch { + // The error already on screen is the one worth showing; a failed + // refetch must not replace it with a second one. + } } finally { setIsLoading(false) } From b26c180e0b688da85fe8cea0d5c5a79c0dd9e93f Mon Sep 17 00:00:00 2001 From: Giuseppe Ciotola <30926550+gciotola@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:46:57 +0200 Subject: [PATCH 21/21] fix: degrade to no organization config instead of rejecting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getOrganizationConfig` rejected when its request failed, and none of its eight call sites caught it. Several are `useEffect` bodies and async click handlers, so the rejection went unhandled and reached the host application — where, under `next dev`, the error overlay covers the page and absorbs every click. One optional setting failing to load took the whole checkout down. `null` is already what this function returns when there is no config to give, and every caller reads the result optionally and falls back to a computed application link, so returning it on a failed request needs no change anywhere else. Only the request is guarded: a bad token or a malformed config is a fault to surface, not a blip to absorb. The log level is load-bearing rather than a matter of taste. `next dev` promotes a `console.error` to an overlay issue whose dialog covers the page just as an unhandled rejection does, so reporting a condition we have just recovered from at that level defeats the point of recovering. Verified both ways by aborting the request and watching a click either land or time out on an element that was plainly visible. Found while building the payment_sessions e2e suite in mfe-checkout, where this turned an intermittent API failure into a suite that could not run. The organization config is fetched several times per page load, so the odds of catching one are better than they look. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/utils/organization.ts | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/react-components/src/utils/organization.ts b/packages/react-components/src/utils/organization.ts index b0fc0a31..8ef88669 100644 --- a/packages/react-components/src/utils/organization.ts +++ b/packages/react-components/src/utils/organization.ts @@ -1,4 +1,5 @@ import { getSdk } from "@commercelayer/core-components" +import type { Organization } from "@commercelayer/sdk" import { type DefaultMfeConfig, getMfeConfig } from "@commercelayer/organization-config" import { useEffect, useState } from "react" import { jwt } from "./jwt" @@ -17,11 +18,36 @@ export async function getOrganizationConfig( ): Promise { const { market } = jwt(config.accessToken) const sdk = getSdk({ accessToken: config.accessToken }) - const organization = await sdk.organization.retrieve({ - fields: { - organizations: ["id", "config"], - }, - }) + + // A network failure here degrades to `null`, the value this function already + // returns when there is no config to give — and every caller reads the result + // optionally, falling back to a computed application link. + // + // Rejecting instead leaves eight call sites to catch the same thing, and none + // of them do: several are `useEffect` bodies and async click handlers, where + // the rejection goes unhandled and reaches the host application. Under + // `next dev` that raises the error overlay, which covers the page and absorbs + // every click — so one optional setting failing to load takes the whole + // checkout down. Only the request is guarded: a bad token or a malformed + // config is a fault to surface, not a blip to absorb. + let organization: Organization + try { + organization = await sdk.organization.retrieve({ + fields: { + organizations: ["id", "config"], + }, + }) + } catch (error) { + // `warn`, not `error`, and the level is load-bearing rather than a matter + // of taste: `next dev` promotes a `console.error` to an overlay issue whose + // dialog covers the page and absorbs every click. Reporting a condition we + // have just recovered from at that level takes the checkout down as surely + // as not catching it at all — verified by aborting this request and + // watching a click time out on an element that was plainly visible. + console.warn("Could not fetch the organization config, continuing without it:", error) + return null + } + return getMfeConfig({ jsonConfig: organization.config ?? {}, market: `market:id:${market.id.join(",")}`,