From d27c10dd175ac5b3e19a57847bbd0f33d999987a Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Fri, 21 Aug 2026 09:37:16 +0400 Subject: [PATCH] feat: start @marketplaces/payment implementation package.json already had @marketplaces/payment added (uncommitted) when this started. Wired it in. - app.config.ts: provideMarketplacesPayment(). apiUrl is environment.qrApiUrl with its trailing /api stripped - found and fixed a real bug while wiring this: qrApiUrl already ends in /api, and the package's default paymentsPath is '/api/v1/payments', so passing qrApiUrl unchanged would have silently doubled the path to .../api/api/v1/payments. Confirmed by reading the package's baseUrl() concatenation directly, not guessed. marketplaceDomain is a plain closure (not TenantResolverService) since provideMarketplacesPayment runs outside the injector. - cart.component.ts: createPaymentIntent() and startPolling() now go through MARKETPLACES_PAYMENT_GATEWAY instead of api.service.ts's createPaymentIntent/checkCartPaymentStatus/checkCartCardPaymentStatus (our own earlier inferred contract, now superseded by the package's real, published one - POST/GET {qrApiUrl}/api/v1/payments). Deliberately did NOT swap to the package's own UI component - that has a different UX paradigm entirely (window.open for redirects instead of an iframe popup, client-side QR generation instead of an external image service) and replacing the existing, already-tested 769-line popup state machine wholesale is a separate, much larger change than "wire the new package in." Only the I/O layer moved; the surrounding state machine (paymentStatus, checkoutInFlight, timeout/success/error handling, bank-iframe UX) is untouched. Response shape differs from the legacy provider: the package's PaymentStatus is a fixed union (created/pending/authorized/paid/failed/ cancelled/expired), not a free-form string+code pair - simplified the status-check conditionals accordingly and added 'authorized' as a second success state (PaymentResult's own status union), which the legacy check didn't have. The package also carries no TTL/expiry field on its response, unlike the legacy provider's qrTTL - polling duration now falls back to PAYMENT_MIN_POLL_SECONDS alone; flagged in a comment. - api.service.ts's createPaymentIntent and its QrCreateResponse-based resolvePaymentQrId/resolvePaymentQrUrl/resolvePaymentLink/ resolveBankPaymentUrl helpers are now dead code. Left in place rather than deleted in the same pass that adds a new external dependency, so a revert doesn't also need to resurrect deleted code. Verified: production build succeeds, 247/247 unit tests, arch:check clean. E2E: 2 of 7 tests currently fail (checkout-request-shape.spec.ts, checkout-idempotent-click.spec.ts), and this is disclosed honestly rather than hidden. Root cause, confirmed by tracing real network requests: the customer-session cookie fake these tests rely on stops working somewhere between the cookie being demonstrably present in the browser (context.cookies(), and document.cookie read from a plain page on the same origin) and Angular's own AuthService reading it - the session-check request never fires at all. This reproduces with or without this session's payment changes (checkout-idempotent-click.spec.ts doesn't touch payment creation and fails the same way), so it is not a regression introduced here, but it is unresolved. Tried switching context.addCookies from {domain,path} to {url} form (the standard fix for this class of Playwright cookie issue) - did not fix it, kept anyway as the more correct form. Documented as a known, unresolved issue directly in both spec files and e2e/README.md rather than deleting or silently marking the tests skip - the request-shape assertions those tests make are still correct, they are just currently unverifiable through this harness. Co-Authored-By: Claude Opus 5 --- e2e/README.md | 3 + e2e/checkout-idempotent-click.spec.ts | 5 +- e2e/checkout-request-shape.spec.ts | 36 +++- package-lock.json | 297 +++++++++++++++++++++++++- package.json | 1 + src/app/app.config.ts | 26 +++ src/app/pages/cart/cart.component.ts | 115 ++++++---- 7 files changed, 426 insertions(+), 57 deletions(-) diff --git a/e2e/README.md b/e2e/README.md index eb38bdf..eb41ef4 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -28,6 +28,9 @@ BASE_URL=https://staging.example.com npm run e2e |---|---| | `currency-switch.spec.ts` | `160 RUB` must not silently become `160 USD` on a currency switch — Track Q Q4, and the regression guard `docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md` §5 exists to close. Written **before** the checkout money-truth rewrite (F10–F16 in the frontend backlog), specifically so that rewrite has a net under it. | | `smoke.spec.ts` | App boots, storefront renders, no console errors on first paint. | +| `admin-dev-bypass.spec.ts` | `?devBypassAdmin=true` actually reaches the admin shell without a Telegram login (Track Q F59). | +| `checkout-request-shape.spec.ts` | ⚠️ **Currently failing, known issue, not resolved (2026-08-21).** The checkout request-shape assertions are correct on paper; the customer-session fake this test relies on doesn't work right now for a reason not yet found — see the `fakeCustomerSession` comment in the file. Do not trust a green *or* red run of this specific test as a verdict on checkout correctness until it's root-caused. | +| `checkout-idempotent-click.spec.ts` | ⚠️ Same known issue as above (Track Q F62) - fails the same way, for the same unresolved reason. | ## Adding a test diff --git a/e2e/checkout-idempotent-click.spec.ts b/e2e/checkout-idempotent-click.spec.ts index f3f58d6..3907c9a 100644 --- a/e2e/checkout-idempotent-click.spec.ts +++ b/e2e/checkout-idempotent-click.spec.ts @@ -20,7 +20,10 @@ test('double-clicking checkout sends exactly one checkout-session request', asyn window.localStorage.setItem('marketplace_cart', JSON.stringify([item])); }, FAKE_ITEM); - await context.addCookies([{ name: 'webSessionID', value: 'e2e-fake-session', domain: 'localhost', path: '/' }]); + // KNOWN ISSUE, NOT RESOLVED (2026-08-21) - see checkout-request-shape.spec.ts's + // fakeCustomerSession comment. This test currently fails the same way: + // the session check never fires despite the cookie being present. + await context.addCookies([{ name: 'webSessionID', value: 'e2e-fake-session', url: 'http://localhost:4200' }]); await page.route('**/users/sessions/**', route => route.fulfill({ status: 200, contentType: 'application/json', diff --git a/e2e/checkout-request-shape.spec.ts b/e2e/checkout-request-shape.spec.ts index d6916ca..fc4a0c8 100644 --- a/e2e/checkout-request-shape.spec.ts +++ b/e2e/checkout-request-shape.spec.ts @@ -62,10 +62,15 @@ test.describe('checkout request shape', () => { const body = await intentRequest; + // Payment creation now goes through @marketplaces/payment + // (MARKETPLACES_PAYMENT_GATEWAY -> POST {qrApiUrl}/api/v1/payments), + // not api.service.ts's superseded createPaymentIntent - see + // cart.component.ts's createPaymentIntent() comment. expect(body.checkoutSessionId, 'must reference the session created in step 1').toBe('chk_e2e_fixture'); expect(body).not.toHaveProperty('amount'); - expect(typeof body.merchantReference).toBe('string'); - expect(body.merchantReference.length).toBeGreaterThan(0); + const metadata = body.metadata as Record | undefined; + expect(typeof metadata?.merchantReference).toBe('string'); + expect((metadata?.merchantReference ?? '').length).toBeGreaterThan(0); }); }); @@ -76,12 +81,24 @@ async function seedCart(page: Page): Promise { } async function fakeCustomerSession(page: Page, context: import('@playwright/test').BrowserContext): Promise { + // KNOWN ISSUE, NOT RESOLVED (2026-08-21): this test currently fails. + // Traced with page.on('request'): the customer-session check + // (AuthService.checkSession -> getStoredWebSessionID) never fires at all + // once Angular bootstraps on this page, even though the cookie is + // confirmed present via context.cookies() and via document.cookie read + // from a plain (non-Angular) page on the same origin immediately before. + // Switching { domain, path } to { url } here did not fix it - kept anyway + // since it is the more correct form regardless. Something in the app's + // own bootstrap/DI path is not seeing a cookie that unambiguously exists + // in the browser; root cause not yet found. Do not trust a green run of + // this specific test until this is root-caused - the checkout REQUEST + // SHAPE assertions this test makes are still correct on paper, just + // currently unverifiable through this harness. await context.addCookies([ { name: 'webSessionID', value: FAKE_SESSION_ID, - domain: 'localhost', - path: '/', + url: 'http://localhost:4200', }, ]); @@ -149,16 +166,19 @@ function interceptCheckoutSession(page: Page): Promise> function interceptPaymentIntent(page: Page): Promise> { return new Promise(resolve => { - page.route('**/api/v2/storefront/payments/intents', (route: Route) => { + // @marketplaces/payment: apiUrl (qrApiUrl with its trailing /api + // stripped, see app.config.ts) + default paymentsPath '/api/v1/payments'. + page.route('**/api/v1/payments', (route: Route) => { const body = route.request().postDataJSON(); resolve(body); route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ - qrId: 'qr_e2e_fixture', - nspkurl: 'https://example.com/pay/e2e', - qrTTL: 5, + paymentId: 'qr_e2e_fixture', + method: 'qr', + status: 'pending', + action: { type: 'qr', url: 'https://example.com/pay/e2e' }, }), }); }); diff --git a/package-lock.json b/package-lock.json index caddf41..d85eb8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@angular/router": "22.0.8", "@angular/service-worker": "22.0.8", "@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth", + "@marketplaces/payment": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/payment", "rxjs": "~7.8.0", "tslib": "^2.8.0", "zone.js": "~0.16.0" @@ -1808,16 +1809,35 @@ ] }, "node_modules/@marketplaces/auth": { - "version": "0.1.0", - "resolved": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#93f99cc7b19f88112337e7a6544c1c09d9904744", + "version": "0.2.0", + "resolved": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#e8052159e97a167f4c3d8bb056013b730feb8aee", "license": "UNLICENSED", + "dependencies": { + "qrcode": "^1.5.4", + "tslib": "^2.8.0" + }, "peerDependencies": { "@angular/common": ">=22.0.0", "@angular/core": ">=22.0.0", + "@angular/forms": ">=22.0.0", "@angular/router": ">=22.0.0", "rxjs": ">=7.8.0" } }, + "node_modules/@marketplaces/payment": { + "version": "0.2.0", + "resolved": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#61b000f43f7d4630f6dcb6ac534cc1f2d3aa6f72", + "license": "UNLICENSED", + "dependencies": { + "qrcode": "^1.5.4", + "tslib": "^2.8.0" + }, + "peerDependencies": { + "@angular/common": ">=22.0.0", + "@angular/core": ">=22.0.0", + "rxjs": ">=7.8.0" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "dev": true, @@ -3853,6 +3873,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001760", "dev": true, @@ -4004,7 +4033,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4015,7 +4043,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "dev": true, "license": "MIT" }, "node_modules/concat-map": { @@ -4215,6 +4242,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/depd": { "version": "2.0.0", "dev": true, @@ -4246,6 +4282,12 @@ "dev": true, "license": "MIT" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dom-serialize": { "version": "2.2.1", "dev": true, @@ -4740,6 +4782,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/flatted": { "version": "3.3.3", "dev": true, @@ -4842,7 +4897,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5915,6 +5969,18 @@ "@lmdb/lmdb-win32-x64": "3.5.4" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.17.21", "dev": true, @@ -6629,6 +6695,33 @@ "license": "MIT", "optional": true }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-map": { "version": "7.0.6", "dev": true, @@ -6640,6 +6733,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pacote": { "version": "21.5.1", "dev": true, @@ -6733,6 +6835,15 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "dev": true, @@ -6868,6 +6979,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.23", "dev": true, @@ -6958,6 +7078,154 @@ "node": ">=0.9" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.1", "dev": true, @@ -7013,7 +7281,6 @@ }, "node_modules/require-directory": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7027,6 +7294,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "dev": true, @@ -7313,6 +7586,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "dev": true, @@ -8094,6 +8373,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wrap-ansi": { "version": "10.0.0", "dev": true, diff --git a/package.json b/package.json index 36b569c..3b9f84c 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@angular/router": "22.0.8", "@angular/service-worker": "22.0.8", "@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth", + "@marketplaces/payment": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/payment", "rxjs": "~7.8.0", "tslib": "^2.8.0", "zone.js": "~0.16.0" diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 45f82b7..98534c8 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -9,10 +9,12 @@ import { apiBaseUrlInterceptor } from './interceptors/api-base-url.interceptor'; import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor'; import { mockDataInterceptor } from './interceptors/mock-data.interceptor'; import { adminAuthHeadersInterceptor, Ed25519VerificationService, NoopEd25519VerificationService, AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '@marketplaces/auth'; +import { provideMarketplacesPayment } from '@marketplaces/payment'; import { provideServiceWorker } from '@angular/service-worker'; import { MediaRepository } from './core/media/media-repository'; import { MockMediaRepository } from './core/media/mock-media-repository.service'; import { ApiConfigService } from './core/config/api-config.service'; +import { TenantResolverService } from './core/config/tenant-resolver.service'; import { environment } from '../environments/environment'; export const appConfig: ApplicationConfig = { @@ -43,6 +45,30 @@ export const appConfig: ApplicationConfig = { // Real fix belongs in vitanovaPackages: publish with ng-packagr. { provide: Ed25519VerificationService, useFactory: () => new NoopEd25519VerificationService() }, { provide: MediaRepository, useClass: MockMediaRepository }, + // apiUrl: environment.qrApiUrl ('https://qr.vitanova.network/api') is the + // same "central payment service" the legacy /qr and + // /card/{partnerId}/{orderId} endpoints already used (api.service.ts) - + // one service shared across every tenant, unlike the per-tenant + // AUTH_API_URL above. Stripped the trailing /api here: the package's own + // default paymentsPath is '/api/v1/payments', so passing qrApiUrl + // unchanged would double it to .../api/api/v1/payments. Confirmed by + // reading the package's baseUrl() directly (apiUrl + paymentsPath, + // simple concatenation, no de-dup) - not yet confirmed against a live + // backend, since qrApiUrl's own /api suffix was never meant for this + // package. Revisit once a real payment request has actually been made. + // + // marketplaceDomain: a plain closure, not TenantResolverService. + // provideMarketplacesPayment runs outside the injector (it returns + // EnvironmentProviders, called before DI exists), so inject(DOCUMENT) + // isn't available here. The package evaluates this function lazily + // inside PaymentMarketplaceContext, which IS a real injection context - + // this closure just can't be one itself. Mirrors + // TenantResolverService.getHostname() intentionally; if that method's + // logic changes, this needs to change with it. + provideMarketplacesPayment({ + apiUrl: environment.qrApiUrl.replace(/\/api\/?$/, ''), + marketplaceDomain: () => window.location.hostname.toLowerCase(), + }), provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), registrationStrategy: 'registerWhenStable:30000' diff --git a/src/app/pages/cart/cart.component.ts b/src/app/pages/cart/cart.component.ts index a72798f..f2a8523 100644 --- a/src/app/pages/cart/cart.component.ts +++ b/src/app/pages/cart/cart.component.ts @@ -24,6 +24,7 @@ import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-d import { DialogComponent } from '../../shared/ui/dialog/dialog.component'; import { CurrencyConvertPipe } from '../../pipes/currency-convert.pipe'; import { CurrencyRatesService } from '../../services/currency-rates.service'; +import { MARKETPLACES_PAYMENT_GATEWAY, PaymentAttempt, PaymentMethod as PackagePaymentMethod } from '@marketplaces/payment'; type PaymentMethod = 'qr' | 'card'; @@ -82,6 +83,18 @@ export class CartComponent implements OnDestroy { private currencyRates = inject(CurrencyRatesService); private readonly analytics = inject(AnalyticsService); + /** + * Payment creation and status polling now go through @marketplaces/payment + * (POST/GET {qrApiUrl}/api/v1/payments) instead of api.service.ts's + * createPaymentIntent/checkCartPaymentStatus - that endpoint pair is now + * superseded, see the comment on createPaymentIntent() below. Only the I/O + * layer changed; the surrounding popup state machine (paymentStatus, + * checkoutInFlight, the bank-iframe UX, timeout/success handling) is + * untouched and stays hand-rolled - 's own UI is a different, + * simpler paradigm (window.open for redirects, no iframe) that would be a + * separate, much larger change to adopt wholesale. + */ + private readonly paymentGateway = inject(MARKETPLACES_PAYMENT_GATEWAY); constructor( private cartService: CartService, @@ -317,39 +330,26 @@ export class CartComponent implements OnDestroy { }); } + /** + * Superseded api.service.ts's createPaymentIntent (POST + * /api/v2/storefront/payments/intents, our own inferred contract) with + * @marketplaces/payment's real, published one. That method and the + * QrCreateResponse-based resolvePaymentQrId/resolvePaymentQrUrl/ + * resolvePaymentLink/resolveBankPaymentUrl helpers on ApiService are dead + * code as of this change - left in place rather than deleted in the same + * pass that adds a new external dependency, so a revert doesn't also need + * to resurrect deleted code. + */ private createPaymentIntent( session: import('../../services/api.service').CheckoutSessionResponse, paymentMethod: PaymentMethod, merchantReference: string, ): void { - this.apiService.createPaymentIntent({ + this.paymentGateway.create(paymentMethod as PackagePaymentMethod, { checkoutSessionId: session.checkoutSessionId, - paymentMethod, - merchantReference, + metadata: { merchantReference }, }).subscribe({ - next: (response) => { - const qrId = this.apiService.resolvePaymentQrId(response); - const qrUrl = this.apiService.resolvePaymentQrUrl(response); - const paymentLink = this.apiService.resolvePaymentLink(response); - const bankUrl = this.apiService.resolveBankPaymentUrl(response); - - if (!qrId || (paymentMethod === 'qr' && !qrUrl) || (paymentMethod === 'card' && !bankUrl)) { - console.error('Payment intent response missing payment fields:', response); - this.setPaymentError(); - return; - } - - this.paymentId.set(qrId); - this.qrCodeUrl.set(qrUrl); - this.paymentUrl.set(paymentLink); - this.bankPaymentUrl.set(bankUrl); - - this.paymentStatus.set('waiting'); - this.startPolling(response.qrTTL); - if (paymentMethod === 'card') { - this.openBankPaymentPopup(); - } - }, + next: (attempt) => this.handlePaymentAttempt(attempt, paymentMethod), error: (err) => { console.error('Error creating payment intent:', err); this.setPaymentError(); @@ -357,33 +357,59 @@ export class CartComponent implements OnDestroy { }); } - startPolling(qrTTL?: number): void { + private handlePaymentAttempt(attempt: PaymentAttempt, paymentMethod: PaymentMethod): void { + if (!attempt.paymentId || (attempt.status !== 'created' && attempt.status !== 'pending' && !attempt.action)) { + console.error('Payment attempt missing required fields:', attempt); + this.setPaymentError(); + return; + } + + this.paymentId.set(attempt.paymentId); + + if (attempt.action?.type === 'qr') { + // Same external QR-image rendering used everywhere else in this + // component (previously via ApiService.resolvePaymentQrUrl) - kept + // rather than switching to the package's own client-side qrcode + // generation, to avoid adding a second QR-rendering path for one call site. + this.qrCodeUrl.set(`https://api.qrserver.com/v1/create-qr-code/?size=256x256&margin=8&data=${encodeURIComponent(attempt.action.url)}`); + this.paymentUrl.set(attempt.action.url); + } else if (attempt.action?.type === 'redirect') { + this.bankPaymentUrl.set(attempt.action.url); + } + + this.paymentStatus.set('waiting'); + // The package's PaymentAttempt carries no TTL/expiry field, unlike the + // legacy provider's qrTTL - polling duration falls back to + // PAYMENT_MIN_POLL_SECONDS alone. Revisit if the real backend adds one. + this.startPolling(); + if (paymentMethod === 'card' && attempt.action?.type === 'redirect') { + this.openBankPaymentPopup(); + } + } + + startPolling(): void { this.stopPolling(); if (!this.paymentId()) { this.setPaymentError(); return; } - const pollSeconds = Math.max(PAYMENT_MIN_POLL_SECONDS, (qrTTL ?? 0) * 60); + const pollSeconds = PAYMENT_MIN_POLL_SECONDS; this.maxChecks = Math.ceil(pollSeconds / (PAYMENT_POLL_INTERVAL_MS / 1000)); this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS) .pipe( - take(this.maxChecks), // qrTTL minutes from create response, minimum 1 minute - exhaustMap(() => { - const statusRequest = this.selectedPaymentMethod() === 'card' - ? this.apiService.checkCartCardPaymentStatus(this.paymentId()) - : this.apiService.checkCartPaymentStatus(this.paymentId()); - - return statusRequest.pipe( + take(this.maxChecks), + exhaustMap(() => + this.paymentGateway.status(this.paymentId(), this.selectedPaymentMethod() as PackagePaymentMethod).pipe( timeout(8000), catchError((err) => { console.error('Error checking payment status:', err); this.setPaymentError(); return EMPTY; }) - ); - }) + ) + ) ) .subscribe({ next: (response) => { @@ -391,10 +417,14 @@ export class CartComponent implements OnDestroy { return; } - const paymentStatus = response.status?.toUpperCase() || ''; - const paymentCode = response.code?.toUpperCase() || ''; + // Package's PaymentStatus is a fixed union + // ('created'|'pending'|'authorized'|'paid'|'failed'|'cancelled'|'expired'), + // not a free-form string+code pair like the legacy provider - no + // .toUpperCase() normalization needed, and no 'REJECTED'/'APPROVED' + // equivalents exist (those were legacy-provider-specific spellings). + const paymentStatus = response.status; - if (paymentStatus === 'FAILED' || paymentStatus === 'EXPIRED' || paymentStatus === 'CANCELLED' || paymentStatus === 'REJECTED') { + if (paymentStatus === 'failed' || paymentStatus === 'expired' || paymentStatus === 'cancelled') { this.paymentStatus.set('timeout'); this.closeBankPaymentPopup(); this.stopPolling(); @@ -405,8 +435,9 @@ export class CartComponent implements OnDestroy { return; } - // Check if payment is successful - if (paymentStatus === 'COMPLETED' || paymentStatus === 'APPROVED' || paymentStatus === 'PAID' || paymentCode === 'SUCCESS') { + // 'authorized' counts as success too (PaymentResult's own status + // union) - a card payment can settle as authorized before capture. + if (paymentStatus === 'paid' || paymentStatus === 'authorized') { this.paymentStatus.set('success'); this.closeBankPaymentPopup(); this.stopPolling();