diff --git a/CHANGELOG.md b/CHANGELOG.md index 069e319..cf64328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.4] - 2025-12-03 + +### Changed +- **BREAKING:** `Session` API aligned with official Satispay documentation + - Changed `type` field to `operation` in session events + - Changed `SessionEventType` enum to `SessionEventOperation` + - Replaced `'ADD_ITEM' | 'REMOVE_ITEM' | 'UPDATE_TOTAL'` with `'ADD' | 'REMOVE'` + - Removed `UPDATE_TOTAL` operation (not supported by Satispay API) + - Made `currency` field mandatory in `SessionEventCreateBody` + +### Added +- E2E tests for fund lock payment creation + +### Fixed +- `Session` types and operations now match official Satispay API specification +- Corrected Session examples in README and `examples/pos-session.ts` +- Corrected Pre-Authorized Payment Tokens documentation in README +- Removed duplicate "Create a Payment" section from README + ## [0.0.3] - 2025-12-03 ### Changed @@ -66,6 +85,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Example files for all major operations (payments, reports, sessions, webhooks, etc.) - Runtime-specific examples for Node.js, Deno, and Bun -[0.0.3]: https://github.com/volverjs/zod-vue-i18n/compare/v0.0.2...v0.0.3 -[0.0.2]: https://github.com/volverjs/zod-vue-i18n/compare/v0.0.1...v0.0.2 +[0.0.4]: https://github.com/volverjs/satispay-node-sdk/compare/v0.0.3...v0.0.4 +[0.0.3]: https://github.com/volverjs/satispay-node-sdk/compare/v0.0.2...v0.0.3 +[0.0.2]: https://github.com/volverjs/satispay-node-sdk/compare/v0.0.1...v0.0.2 [0.0.1]: https://github.com/volverjs/satispay-node-sdk/releases/tag/v0.0.1 diff --git a/README.md b/README.md index ac2faa4..366c53b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Universal (but unofficial) TypeScript SDK for Satispay GBusiness API integration - **Zero dependencies** - Uses only native standard APIs (fetch, crypto) - **Multi-runtime** - Works with Node.js 18+, Deno 1.30+, and Bun 1.0+ -- **Lightweight** - Only 156KB bundle size +- **Lightweight** - Only 268KB bundle size - **Type-safe** - Complete TypeScript definitions - **Modern** - Fetch API, async/await, ES Modules - **Secure** - Native RSA-SHA256 encryption @@ -98,29 +98,6 @@ SATISPAY_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n..." SATISPAY_KEY_ID="your-key-id" ``` -### Create a Payment - -```typescript -import { Payment } from '@volverjs/satispay-node-sdk'; - -// Using amount in euros (recommended) -const payment = await Payment.create({ - flow: 'MATCH_CODE', - amount: 1.99, // Amount in euros (automatically converted to cents) - currency: 'EUR', -}); - -// Or using amount_unit in cents (still supported) -const payment2 = await Payment.create({ - flow: 'MATCH_CODE', - amount_unit: 199, // Amount in cents (1.99 EUR) - currency: 'EUR', -}); - -console.log('Payment ID:', payment.id); -console.log('Code:', payment.code_identifier); -``` - ## API Reference ### Payment Operations @@ -247,24 +224,44 @@ console.log('Refunds:', closure.shop_daily_closure.refund_amount_unit / 100); ### Pre-Authorized Payment Tokens +Pre-Authorized Payment Tokens allow consumers to authorize payments in advance: + ```typescript import { PreAuthorizedPaymentToken } from '@volverjs/satispay-node-sdk'; -// Create token +// Create a pre-authorized payment token const token = await PreAuthorizedPaymentToken.create({ - flow: 'MATCH_CODE', - consumer_uid: 'CONSUMER_UID', + reason: 'Subscription payment', + callback_url: 'https://your-site.com/callback', + redirect_url: 'https://your-site.com/success', }); -// Get token +console.log('Token ID:', token.id); +console.log('Token:', token.token); +console.log('Status:', token.status); // PENDING + +// Get token details const retrievedToken = await PreAuthorizedPaymentToken.get(token.id); -// Update token +// Update token (e.g., cancel it) const updatedToken = await PreAuthorizedPaymentToken.update(token.id, { status: 'CANCELED', }); + +// Once the consumer accepts the token, you can use it to create payments: +const payment = await Payment.create({ + flow: 'PRE_AUTHORIZED', + token: token.token, + amount: 9.99, + currency: 'EUR', +}); ``` +**Important Notes:** +- The consumer must accept the token before it can be used for payments +- Token status can be: `PENDING`, `ACCEPTED`, or `CANCELED` +- Use the `token` field (not the `id`) when creating pre-authorized payments + ### Reports > **⚠️ Special Authentication Required**: Report APIs require special authentication keys. Contact tech@satispay.com to enable access. @@ -317,25 +314,21 @@ console.log('Available amount:', session.residual_amount_unit); // Add items to the session await Session.createEvent(session.id, { - type: 'ADD_ITEM', + operation: 'ADD', amount_unit: 500, + currency: 'EUR', description: 'Coffee', metadata: { sku: 'COFFEE-001' }, }); -// Remove items +// Remove items (e.g., discount) await Session.createEvent(session.id, { - type: 'REMOVE_ITEM', + operation: 'REMOVE', amount_unit: 200, + currency: 'EUR', description: 'Discount', }); -// Update total -await Session.createEvent(session.id, { - type: 'UPDATE_TOTAL', - amount_unit: 300, -}); - // Get session details const details = await Session.get(session.id); console.log('Residual amount:', details.residual_amount_unit); @@ -548,7 +541,7 @@ This project uses [Vitest](https://vitest.dev/) for testing. The test suite incl - **RSA Service tests** for cryptographic operations - **Mock-based tests** for API interactions -Current test coverage: **79.45%** +Current test coverage: **94.82%** (Statements: 93.83%, Branches: 89.65%, Functions: 100%, Lines: 95.8%) Run tests: ```bash @@ -605,7 +598,7 @@ Benefits: ## Why Zero Dependencies? - No third-party vulnerability risks -- Minimal bundle size (156KB) +- Minimal bundle size (268KB) - Fast installation - No dependency conflicts - Uses only standard APIs diff --git a/examples/pos-session.ts b/examples/pos-session.ts index cce4973..130fead 100644 --- a/examples/pos-session.ts +++ b/examples/pos-session.ts @@ -67,46 +67,48 @@ async function main() { console.log('Residual amount:', session.residual_amount_unit / 100, 'EUR') console.log('Status:', session.status) - // Step 3: Add items to the session - console.log('\n3️⃣ Adding items to session...\n') - - // Add coffee - await Session.createEvent(session.id, { - type: 'ADD_ITEM', - amount_unit: 300, // 3.00 EUR - description: 'Espresso', - metadata: { sku: 'COFFEE-001', category: 'beverages' }, - }) - console.log('✅ Added: Espresso (3.00 EUR)') - - // Add croissant - await Session.createEvent(session.id, { - type: 'ADD_ITEM', - amount_unit: 250, // 2.50 EUR - description: 'Croissant', - metadata: { sku: 'PASTRY-042', category: 'food' }, - }) - console.log('✅ Added: Croissant (2.50 EUR)') - - // Add water - await Session.createEvent(session.id, { - type: 'ADD_ITEM', - amount_unit: 150, // 1.50 EUR - description: 'Water', - metadata: { sku: 'DRINK-010', category: 'beverages' }, - }) - console.log('✅ Added: Water (1.50 EUR)') - - // Apply discount - console.log('\n4️⃣ Applying discount...\n') - await Session.createEvent(session.id, { - type: 'REMOVE_ITEM', - amount_unit: 100, // -1.00 EUR discount - description: 'Happy Hour Discount', - }) - console.log('✅ Applied discount: -1.00 EUR') + // Step 3: Add items to the session + console.log('\n3️⃣ Adding items to session...\n') - // Check session status + // Add coffee + await Session.createEvent(session.id, { + operation: 'ADD', + amount_unit: 300, // 3.00 EUR + currency: 'EUR', + description: 'Espresso', + metadata: { sku: 'COFFEE-001', category: 'beverages' }, + }) + console.log('✅ Added: Espresso (3.00 EUR)') + + // Add croissant + await Session.createEvent(session.id, { + operation: 'ADD', + amount_unit: 250, // 2.50 EUR + currency: 'EUR', + description: 'Croissant', + metadata: { sku: 'PASTRY-042', category: 'food' }, + }) + console.log('✅ Added: Croissant (2.50 EUR)') + + // Add water + await Session.createEvent(session.id, { + operation: 'ADD', + amount_unit: 150, // 1.50 EUR + currency: 'EUR', + description: 'Water', + metadata: { sku: 'DRINK-010', category: 'beverages' }, + }) + console.log('✅ Added: Water (1.50 EUR)') + + // Apply discount + console.log('\n4️⃣ Applying discount...\n') + await Session.createEvent(session.id, { + operation: 'REMOVE', + amount_unit: 100, // -1.00 EUR discount + currency: 'EUR', + description: 'Happy Hour Discount', + }) + console.log('✅ Applied discount: -1.00 EUR') // Check session status console.log('\n5️⃣ Checking session status...\n') const sessionDetails = await Session.get(session.id) console.log('Total amount:', sessionDetails.amount_unit / 100, 'EUR') diff --git a/src/Session.ts b/src/Session.ts index 6dd918e..23eab90 100644 --- a/src/Session.ts +++ b/src/Session.ts @@ -25,8 +25,9 @@ import type { * * // Add items to the session * await Session.createEvent(session.id, { - * type: 'ADD_ITEM', + * operation: 'ADD', * amount_unit: 500, + * currency: 'EUR', * description: 'Product A' * }); * @@ -140,16 +141,18 @@ export class Session { * ```typescript * // Add an item to the session * await Session.createEvent('session-123', { - * type: 'ADD_ITEM', + * operation: 'ADD', * amount_unit: 1000, + * currency: 'EUR', * description: 'Coffee', * metadata: { sku: 'COFFEE-001' } * }); * * // Remove an item * await Session.createEvent('session-123', { - * type: 'REMOVE_ITEM', + * operation: 'REMOVE', * amount_unit: 500, + * currency: 'EUR', * description: 'Discount applied' * }); * ``` diff --git a/src/types.ts b/src/types.ts index 69e2f54..e5b9644 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,9 +63,9 @@ export type ReportStatus = 'PENDING' | 'READY' | 'FAILED' export type SessionStatus = 'OPEN' | 'CLOSE' /** - * Session event type + * Session event operation */ -export type SessionEventType = 'ADD_ITEM' | 'REMOVE_ITEM' | 'UPDATE_TOTAL' +export type SessionEventOperation = 'ADD' | 'REMOVE' /** * Payment action @@ -315,8 +315,9 @@ export type SessionUpdateBody = { * Session event creation body */ export type SessionEventCreateBody = { - type: SessionEventType - amount_unit?: number + operation: SessionEventOperation + amount_unit: number + currency: string description?: string metadata?: Record } diff --git a/tests/Session.test.ts b/tests/Session.test.ts index 36ee35d..19475f4 100644 --- a/tests/Session.test.ts +++ b/tests/Session.test.ts @@ -174,94 +174,75 @@ describe('Session', () => { }) describe('createEvent', () => { - it('should add an item to the session', async () => { - const sessionId = 'session-123' - const eventBody: SessionEventCreateBody = { - type: 'ADD_ITEM', - amount_unit: 1000, - description: 'Coffee', - } - const updatedSession: SessionResponse = { - ...mockSessionResponse, - residual_amount_unit: 4000, - } - - vi.mocked(Request.post).mockResolvedValue(updatedSession) - - const result = await Session.createEvent(sessionId, eventBody) - - expect(Request.post).toHaveBeenCalledWith( - '/g_business/v1/sessions/session-123/events', - { - headers: {}, - body: eventBody, - sign: true, - }, - ) - expect(result.residual_amount_unit).toBe(4000) - }) - - it('should remove an item from the session', async () => { - const sessionId = 'session-123' - const eventBody: SessionEventCreateBody = { - type: 'REMOVE_ITEM', - amount_unit: 500, - description: 'Discount applied', - } - - vi.mocked(Request.post).mockResolvedValue(mockSessionResponse) - - await Session.createEvent(sessionId, eventBody) - - expect(Request.post).toHaveBeenCalledWith( - '/g_business/v1/sessions/session-123/events', - { - headers: {}, - body: eventBody, - sign: true, - }, - ) - }) + it('should add an item to the session', async () => { + const sessionId = 'session-123' + const eventBody: SessionEventCreateBody = { + operation: 'ADD', + amount_unit: 1000, + currency: 'EUR', + description: 'Coffee', + } + const updatedSession: SessionResponse = { + ...mockSessionResponse, + residual_amount_unit: 4000, + } + + vi.mocked(Request.post).mockResolvedValue(updatedSession) + + const result = await Session.createEvent(sessionId, eventBody) + + expect(Request.post).toHaveBeenCalledWith( + '/g_business/v1/sessions/session-123/events', + { + headers: {}, + body: eventBody, + sign: true, + }, + ) + expect(result.residual_amount_unit).toBe(4000) + }) - it('should update the total', async () => { - const sessionId = 'session-123' - const eventBody: SessionEventCreateBody = { - type: 'UPDATE_TOTAL', - amount_unit: 3500, - description: 'Total updated', - } + it('should remove an item from the session', async () => { + const sessionId = 'session-123' + const eventBody: SessionEventCreateBody = { + operation: 'REMOVE', + amount_unit: 500, + currency: 'EUR', + description: 'Discount applied', + } - vi.mocked(Request.post).mockResolvedValue(mockSessionResponse) + vi.mocked(Request.post).mockResolvedValue(mockSessionResponse) - await Session.createEvent(sessionId, eventBody) + await Session.createEvent(sessionId, eventBody) - expect(Request.post).toHaveBeenCalledWith( - '/g_business/v1/sessions/session-123/events', - { - headers: {}, - body: eventBody, - sign: true, - }, - ) - }) + expect(Request.post).toHaveBeenCalledWith( + '/g_business/v1/sessions/session-123/events', + { + headers: {}, + body: eventBody, + sign: true, + }, + ) + }) - it('should create event with metadata', async () => { - const sessionId = 'session-123' - const eventBody: SessionEventCreateBody = { - type: 'ADD_ITEM', - amount_unit: 1200, - description: 'Espresso', - metadata: { - sku: 'COFFEE-001', - category: 'beverages', - }, - } + it('should create event with metadata', async () => { + const sessionId = 'session-123' + const eventBody: SessionEventCreateBody = { + operation: 'ADD', + amount_unit: 1200, + currency: 'EUR', + description: 'Espresso', + metadata: { + sku: 'COFFEE-001', + category: 'beverages', + }, + } - vi.mocked(Request.post).mockResolvedValue(mockSessionResponse) + vi.mocked(Request.post).mockResolvedValue(mockSessionResponse) - await Session.createEvent(sessionId, eventBody) + await Session.createEvent(sessionId, eventBody) - expect(Request.post).toHaveBeenCalledWith( + expect(Request.post).toHaveBeenCalledWith( '/g_business/v1/sessions/session-123/events', expect.objectContaining({ body: expect.objectContaining({ @@ -277,8 +258,9 @@ describe('Session', () => { it('should create event with custom headers', async () => { const sessionId = 'session-123' const eventBody: SessionEventCreateBody = { - type: 'ADD_ITEM', + operation: 'ADD', amount_unit: 800, + currency: 'EUR', } const customHeaders = { 'Idempotency-Key': 'event-unique-123', diff --git a/tests/e2e/session.e2e.test.ts b/tests/e2e/session.e2e.test.ts new file mode 100644 index 0000000..572586d --- /dev/null +++ b/tests/e2e/session.e2e.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { Payment } from '../../src/Payment' +import { CodeGenerator } from '../../src/utils' +import { canRunE2ETests, hasAuthenticationKeys } from '../setup' + +/** + * E2E tests for Fund Lock Payment with Satispay + * + * These tests require: + * - SATISPAY_PUBLIC_KEY, SATISPAY_PRIVATE_KEY, SATISPAY_KEY_ID configured + * - Staging or test environment + * + * NOTE: These tests create real fund lock payments. + * Use only with test/staging environments. + */ + +describe.skipIf(!canRunE2ETests() || !hasAuthenticationKeys())('E2E: Fund Lock Payment', () => { + let fundLockPaymentId: string | undefined + + beforeAll(() => { + console.log('\n⚠️ WARNING: These tests create real fund lock payments') + console.log('Make sure you are in staging/test environment\n') + }) + + it('should create a fund lock payment', async () => { + const externalCode = CodeGenerator.generateExternalCode('E2E-FUNDLOCK') + const amount = 50.00 // 50 euros + + const payment = await Payment.create({ + flow: 'FUND_LOCK', + amount: amount, + currency: 'EUR', + external_code: externalCode, + metadata: { + test: 'e2e-fundlock-test', + timestamp: new Date().toISOString(), + }, + }) + + // Verify response + expect(payment.id).toBeTruthy() + expect(payment.amount_unit).toBe(5000) // 50 EUR in cents + expect(payment.currency).toBe('EUR') + expect(payment.external_code).toBe(externalCode) + expect(payment.status).toBe('PENDING') + expect(payment.code_identifier).toBeTruthy() + + fundLockPaymentId = payment.id + + console.log('\n✅ Fund lock created:', payment.id) + console.log('📱 Code to scan:', payment.code_identifier) + console.log('⏳ Status:', payment.status) + }, 10000) + + it('should get fund lock payment details', async () => { + expect(fundLockPaymentId).toBeTruthy() + + const payment = await Payment.get(fundLockPaymentId!) + + expect(payment.id).toBe(fundLockPaymentId) + expect(payment.amount_unit).toBe(5000) + expect(payment.currency).toBe('EUR') + + console.log('📊 Fund lock status:', payment.status) + console.log('💰 Amount:', payment.amount_unit / 100, 'EUR') + }) +})