diff --git a/Gemfile.lock b/Gemfile.lock index 2bba06a..7805b53 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - zai_payment (2.8.6) + zai_payment (2.9.0) base64 (~> 0.3.0) faraday (~> 2.0) openssl (~> 3.3) diff --git a/changelog.md b/changelog.md index b488225..0ce35ca 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,27 @@ ## [Released] +## [2.9.0] - 2025-12-16 + +### Added +- **Webhook Jobs API**: Retrieve and monitor webhook delivery jobs 📋 + - `ZaiPayment.webhooks.list_jobs(webhook_id, limit:, offset:, status:, object_id:)` - List jobs associated with a webhook + - `ZaiPayment.webhooks.show_job(webhook_id, job_id)` - Get details of a specific webhook job + - Support for pagination with `limit` (1-200) and `offset` parameters + - Support for filtering by `status` ('success' or 'failed') + - Support for filtering by `object_id` + - Job details include: `uuid`, `webhook_uuid`, `object_id`, `payload`, `request_responses`, `created_at`, `updated_at` + - Request responses include delivery attempts with `response_code`, `message`, and timestamps + - Validation for status parameter (must be 'success' or 'failed') + - Full RSpec test suite with 21 test examples + - Comprehensive YARD documentation with examples + +### Enhanced +- **Response Class**: Added `jobs` to `RESPONSE_DATA_KEYS` for automatic data extraction + - `response.data` now properly extracts jobs array from list_jobs responses + - Consistent with other resource response handling + +**Full Changelog**: https://github.com/Sentia/zai-payment/compare/v2.8.6...v2.9.0 + ## [2.8.6] - 2025-12-12 ### Fixed diff --git a/lib/zai_payment/resources/webhook.rb b/lib/zai_payment/resources/webhook.rb index 60d1ce7..63d7972 100644 --- a/lib/zai_payment/resources/webhook.rb +++ b/lib/zai_payment/resources/webhook.rb @@ -133,6 +133,62 @@ def delete(webhook_id) client.delete("/webhooks/#{webhook_id}") end + # List jobs associated with a webhook + # + # Retrieves an ordered and paginated list of jobs garnered from a webhook. + # + # @param webhook_id [String] the webhook ID (UUID) + # @param limit [Integer] number of records to retrieve (1-200, default: 10) + # @param offset [Integer] number of records to skip (default: 0) + # @param status [String] filter by status ('success', 'failed', or nil for all) + # @param object_id [String] filter by object_id + # @return [Response] the API response containing jobs array + # + # @example List all jobs for a webhook + # webhooks = ZaiPayment::Resources::Webhook.new + # response = webhooks.list_jobs("webhook_uuid") + # response.data # => [{"id" => "...", "status" => "success", ...}, ...] + # + # @example Filter jobs by status + # response = webhooks.list_jobs("webhook_uuid", status: "failed") + # + # @example Paginate through jobs + # response = webhooks.list_jobs("webhook_uuid", limit: 50, offset: 100) + # + # @see https://developer.hellozai.com/reference/getjobs + def list_jobs(webhook_id, limit: 10, offset: 0, status: nil, object_id: nil) + validate_id!(webhook_id, 'webhook_id') + validate_job_status!(status) if status + + params = { + limit: limit, + offset: offset + } + params[:status] = status if status + params[:object_id] = object_id if object_id + + client.get("/webhooks/#{webhook_id}/jobs", params: params) + end + + # Show a specific job associated with a webhook + # + # @param webhook_id [String] the webhook ID (UUID) + # @param job_id [String] the job ID + # @return [Response] the API response containing job details + # + # @example + # webhooks = ZaiPayment::Resources::Webhook.new + # response = webhooks.show_job("webhook_uuid", "job_id") + # response.data # => {"id" => "job_id", "status" => "success", ...} + # + # @see https://developer.hellozai.com/reference/getjob + def show_job(webhook_id, job_id) + validate_id!(webhook_id, 'webhook_id') + validate_id!(job_id, 'job_id') + + client.get("/webhooks/#{webhook_id}/jobs/#{job_id}") + end + # Create a secret key for webhook signature verification # # @param secret_key [String] the secret key to use for HMAC signature generation @@ -262,6 +318,14 @@ def validate_secret_key!(secret_key) raise Errors::ValidationError, 'secret_key must be at least 32 bytes in size' end + def validate_job_status!(status) + valid_statuses = %w[success failed] + return if valid_statuses.include?(status) + + raise Errors::ValidationError, + "status must be one of: #{valid_statuses.join(', ')}" + end + def parse_signature_header(header) # Format: "t=1257894000,v=signature1,v=signature2" parts = header.split(',').map(&:strip) diff --git a/lib/zai_payment/response.rb b/lib/zai_payment/response.rb index 32469d9..7b32478 100644 --- a/lib/zai_payment/response.rb +++ b/lib/zai_payment/response.rb @@ -6,7 +6,7 @@ class Response attr_reader :status, :body, :headers, :raw_response RESPONSE_DATA_KEYS = %w[ - webhooks users items fees transactions + webhooks users items fees transactions jobs batch_transactions batches bpay_accounts bank_accounts card_accounts wallet_accounts virtual_accounts disbursements pay_ids ].freeze diff --git a/lib/zai_payment/version.rb b/lib/zai_payment/version.rb index 8caa5ed..7ea43fa 100644 --- a/lib/zai_payment/version.rb +++ b/lib/zai_payment/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module ZaiPayment - VERSION = '2.8.6' + VERSION = '2.9.0' end diff --git a/spec/zai_payment/resources/webhook_spec.rb b/spec/zai_payment/resources/webhook_spec.rb index 8a7f2bb..6d72fa3 100644 --- a/spec/zai_payment/resources/webhook_spec.rb +++ b/spec/zai_payment/resources/webhook_spec.rb @@ -354,6 +354,278 @@ end end + describe '#list_jobs' do + let(:webhook_id) { 'webhook_123' } + + context 'when successful' do + before do + stubs.get("/webhooks/#{webhook_id}/jobs") do |env| + [200, { 'Content-Type' => 'application/json' }, job_list_data] if env.params['limit'] == '10' + end + end + + let(:job_list_data) do + { + 'jobs' => [ + { + 'id' => 'job_1', + 'status' => 'success', + 'object_id' => 'item_123', + 'created_at' => '2024-01-15T10:30:00Z' + }, + { + 'id' => 'job_2', + 'status' => 'failed', + 'object_id' => 'item_456', + 'created_at' => '2024-01-15T10:31:00Z' + } + ], + 'meta' => { + 'total' => 2, + 'limit' => 10, + 'offset' => 0 + } + } + end + + it 'returns the correct response type' do + response = webhook_resource.list_jobs(webhook_id) + expect(response).to be_a(ZaiPayment::Response) + expect(response.success?).to be true + end + + it 'returns the jobs data' do + response = webhook_resource.list_jobs(webhook_id) + expect(response.data).to eq(job_list_data['jobs']) + end + + it 'returns the metadata' do + response = webhook_resource.list_jobs(webhook_id) + expect(response.meta).to eq(job_list_data['meta']) + end + end + + context 'with custom pagination' do + before do + stubs.get("/webhooks/#{webhook_id}/jobs") do |env| + if env.params['limit'] == '50' && env.params['offset'] == '100' + [200, { 'Content-Type' => 'application/json' }, paginated_data] + end + end + end + + let(:paginated_data) do + { + 'jobs' => [], + 'meta' => { 'total' => 0, 'limit' => 50, 'offset' => 100 } + } + end + + it 'accepts custom limit and offset' do + response = webhook_resource.list_jobs(webhook_id, limit: 50, offset: 100) + expect(response.success?).to be true + end + end + + context 'with status filter' do + before do + stubs.get("/webhooks/#{webhook_id}/jobs") do |env| + [200, { 'Content-Type' => 'application/json' }, filtered_data] if env.params['status'] == 'success' + end + end + + let(:filtered_data) do + { + 'jobs' => [{ 'id' => 'job_1', 'status' => 'success' }], + 'meta' => { 'total' => 1, 'limit' => 10, 'offset' => 0 } + } + end + + it 'filters jobs by status' do + response = webhook_resource.list_jobs(webhook_id, status: 'success') + expect(response.success?).to be true + expect(response.data.first['status']).to eq('success') + end + end + + context 'with object_id filter' do + before do + stubs.get("/webhooks/#{webhook_id}/jobs") do |env| + [200, { 'Content-Type' => 'application/json' }, filtered_data] if env.params['object_id'] == 'item_123' + end + end + + let(:filtered_data) do + { + 'jobs' => [{ 'id' => 'job_1', 'object_id' => 'item_123' }], + 'meta' => { 'total' => 1, 'limit' => 10, 'offset' => 0 } + } + end + + it 'filters jobs by object_id' do + response = webhook_resource.list_jobs(webhook_id, object_id: 'item_123') + expect(response.success?).to be true + expect(response.data.first['object_id']).to eq('item_123') + end + end + + context 'when webhook_id is blank' do + it 'raises a ValidationError for empty string' do + expect { webhook_resource.list_jobs('') } + .to raise_error(ZaiPayment::Errors::ValidationError, /webhook_id/) + end + + it 'raises a ValidationError for nil' do + expect { webhook_resource.list_jobs(nil) } + .to raise_error(ZaiPayment::Errors::ValidationError, /webhook_id/) + end + end + + context 'when status is invalid' do + it 'raises a ValidationError' do + expect { webhook_resource.list_jobs(webhook_id, status: 'invalid') } + .to raise_error(ZaiPayment::Errors::ValidationError, /status must be one of/) + end + end + + context 'when webhook does not exist' do + before do + stubs.get("/webhooks/#{webhook_id}/jobs") do + [404, { 'Content-Type' => 'application/json' }, { 'error' => 'Webhook not found' }] + end + end + + it 'raises a NotFoundError' do + expect { webhook_resource.list_jobs(webhook_id) } + .to raise_error(ZaiPayment::Errors::NotFoundError) + end + end + + context 'when unauthorized' do + before do + stubs.get("/webhooks/#{webhook_id}/jobs") do + [401, { 'Content-Type' => 'application/json' }, { 'error' => 'Unauthorized' }] + end + end + + it 'raises an UnauthorizedError' do + expect { webhook_resource.list_jobs(webhook_id) } + .to raise_error(ZaiPayment::Errors::UnauthorizedError) + end + end + end + + describe '#show_job' do + let(:webhook_id) { 'webhook_123' } + let(:job_id) { 'job_456' } + + context 'when job exists' do + before do + stubs.get("/webhooks/#{webhook_id}/jobs/#{job_id}") do + [200, { 'Content-Type' => 'application/json' }, { + 'hashed_payload' => '32187', + 'updated_at' => '2009-11-11T18:00:00+12:00', + 'created_at' => '2021-03-18T05:31:32.867255Z', + 'object_id' => 'buyer-123456', + 'payload' => { + 'accounts' => { + 'account_type_id' => 9100, + 'amount' => 0, + 'uuid' => '6f348690-f2d7-0137-3328-0242ac110003' + } + }, + 'webhook_uuid' => webhook_id, + 'uuid' => job_id, + 'request_responses' => [ + { 'response_code' => 500, 'message' => '', 'created_at' => '2021-05-24T06:54:32.019211768Z' }, + { 'response_code' => 202, 'message' => '', 'created_at' => '2021-05-24T07:24:34.156212905Z' } + ], + 'links' => { + 'self' => "/webhooks/#{webhook_id}/jobs/#{job_id}", + 'jobs' => "/webhooks/#{webhook_id}/jobs/" + } + }] + end + end + + it 'returns the correct response type' do + response = webhook_resource.show_job(webhook_id, job_id) + expect(response).to be_a(ZaiPayment::Response) + expect(response.success?).to be true + end + + it 'returns the job details' do + response = webhook_resource.show_job(webhook_id, job_id) + expect(response.data['uuid']).to eq(job_id) + expect(response.data['webhook_uuid']).to eq(webhook_id) + expect(response.data['object_id']).to eq('buyer-123456') + end + + it 'includes request_responses' do + response = webhook_resource.show_job(webhook_id, job_id) + expect(response.data['request_responses']).to be_an(Array) + expect(response.data['request_responses'].length).to eq(2) + expect(response.data['request_responses'].last['response_code']).to eq(202) + end + + it 'includes payload data' do + response = webhook_resource.show_job(webhook_id, job_id) + expect(response.data['payload']).to be_a(Hash) + expect(response.data['payload']['accounts']['account_type_id']).to eq(9100) + end + end + + context 'when webhook_id is blank' do + it 'raises a ValidationError for empty string' do + expect { webhook_resource.show_job('', job_id) } + .to raise_error(ZaiPayment::Errors::ValidationError, /webhook_id/) + end + + it 'raises a ValidationError for nil' do + expect { webhook_resource.show_job(nil, job_id) } + .to raise_error(ZaiPayment::Errors::ValidationError, /webhook_id/) + end + end + + context 'when job_id is blank' do + it 'raises a ValidationError for empty string' do + expect { webhook_resource.show_job(webhook_id, '') } + .to raise_error(ZaiPayment::Errors::ValidationError, /job_id/) + end + + it 'raises a ValidationError for nil' do + expect { webhook_resource.show_job(webhook_id, nil) } + .to raise_error(ZaiPayment::Errors::ValidationError, /job_id/) + end + end + + context 'when job does not exist' do + before do + stubs.get("/webhooks/#{webhook_id}/jobs/#{job_id}") do + [404, { 'Content-Type' => 'application/json' }, { 'error' => 'Job not found' }] + end + end + + it 'raises a NotFoundError' do + expect { webhook_resource.show_job(webhook_id, job_id) } + .to raise_error(ZaiPayment::Errors::NotFoundError) + end + end + + context 'when webhook does not exist' do + before do + stubs.get("/webhooks/#{webhook_id}/jobs/#{job_id}") do + [404, { 'Content-Type' => 'application/json' }, { 'error' => 'Webhook not found' }] + end + end + + it 'raises a NotFoundError' do + expect { webhook_resource.show_job(webhook_id, job_id) } + .to raise_error(ZaiPayment::Errors::NotFoundError) + end + end + end + describe '#create_secret_key' do let(:secret_key) { 'a' * 32 } # 32 byte secret key