guesty-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +34 -0
  3. data/LICENSE +21 -0
  4. data/README.md +178 -0
  5. data/lib/guesty/access_tokens/upsert.rb +61 -0
  6. data/lib/guesty/api_error.rb +73 -0
  7. data/lib/guesty/client.rb +138 -0
  8. data/lib/guesty/config.rb +29 -0
  9. data/lib/guesty/conversations/attachments/index.rb +42 -0
  10. data/lib/guesty/conversations/index.rb +58 -0
  11. data/lib/guesty/conversations/messages/reply.rb +55 -0
  12. data/lib/guesty/conversations/posts/index.rb +121 -0
  13. data/lib/guesty/conversations/posts/since.rb +37 -0
  14. data/lib/guesty/conversations/search.rb +31 -0
  15. data/lib/guesty/conversations/show.rb +25 -0
  16. data/lib/guesty/conversations.rb +15 -0
  17. data/lib/guesty/error.rb +32 -0
  18. data/lib/guesty/guest_folio/invoice_items/index.rb +61 -0
  19. data/lib/guesty/guests/profiles/create.rb +25 -0
  20. data/lib/guesty/guests/profiles/show.rb +23 -0
  21. data/lib/guesty/guests/profiles/update.rb +36 -0
  22. data/lib/guesty/guests.rb +5 -0
  23. data/lib/guesty/homes/calendars/show.rb +134 -0
  24. data/lib/guesty/homes/search.rb +46 -0
  25. data/lib/guesty/memory_rate_limiter.rb +28 -0
  26. data/lib/guesty/memory_token_store.rb +33 -0
  27. data/lib/guesty/operation.rb +37 -0
  28. data/lib/guesty/rate_limiter_middleware.rb +23 -0
  29. data/lib/guesty/reservations/accommodation_fare/update.rb +44 -0
  30. data/lib/guesty/reservations/approve.rb +23 -0
  31. data/lib/guesty/reservations/create.rb +50 -0
  32. data/lib/guesty/reservations/custom_fields/update.rb +26 -0
  33. data/lib/guesty/reservations/dates/update.rb +41 -0
  34. data/lib/guesty/reservations/decline.rb +30 -0
  35. data/lib/guesty/reservations/flags/update.rb +33 -0
  36. data/lib/guesty/reservations/payments/create.rb +72 -0
  37. data/lib/guesty/reservations/pre_approve.rb +23 -0
  38. data/lib/guesty/reservations/recover.rb +27 -0
  39. data/lib/guesty/reservations/reviews/extractors/airbnb.rb +66 -0
  40. data/lib/guesty/reservations/reviews/extractors/base.rb +36 -0
  41. data/lib/guesty/reservations/reviews/extractors/booking_com.rb +35 -0
  42. data/lib/guesty/reservations/reviews/extractors/vrbo.rb +46 -0
  43. data/lib/guesty/reservations/reviews/index.rb +67 -0
  44. data/lib/guesty/reservations/search.rb +63 -0
  45. data/lib/guesty/reservations/show.rb +28 -0
  46. data/lib/guesty/reservations/status/cancel.rb +34 -0
  47. data/lib/guesty/reservations/status/update.rb +21 -0
  48. data/lib/guesty/reservations/stays/update.rb +42 -0
  49. data/lib/guesty/reservations/update.rb +19 -0
  50. data/lib/guesty/reservations.rb +13 -0
  51. data/lib/guesty/reviews/respond.rb +19 -0
  52. data/lib/guesty/version.rb +3 -0
  53. data/lib/guesty/views/create.rb +19 -0
  54. data/lib/guesty/views/update.rb +33 -0
  55. data/lib/guesty/webhooks/create.rb +21 -0
  56. data/lib/guesty/webhooks/index.rb +18 -0
  57. data/lib/guesty/webhooks/signatures/verify.rb +76 -0
  58. data/lib/guesty/webhooks/sync.rb +36 -0
  59. data/lib/guesty/webhooks/update.rb +22 -0
  60. data/lib/guesty-client.rb +1 -0
  61. data/lib/guesty.rb +78 -0
  62. data/sig/guesty.rbs +427 -0
  63. metadata +191 -0
@@ -0,0 +1,55 @@
1
+ module Guesty
2
+ module Conversations
3
+ module Messages
4
+ class Reply
5
+ include Guesty::Operation
6
+
7
+ # Guesty module type per booking channel; direct channels go by email.
8
+ BOOKING_MODULE_TYPES = {
9
+ 'airbnb' => 'airbnb2',
10
+ 'booking_com' => 'bookingCom',
11
+ 'guestit' => 'email',
12
+ 'core_manual' => 'email',
13
+ 'core_group_manual' => 'email',
14
+ 'ago' => 'email',
15
+ 'owner_stay' => 'email'
16
+ }.freeze #: ::Hash[::String, ::String]
17
+
18
+ # @rbs (::String? channel_name) -> ::String?
19
+ def self.module_type_for(channel_name)
20
+ BOOKING_MODULE_TYPES[channel_name]
21
+ end
22
+
23
+ # @rbs (conversation_id: String, body: String, ?module_type: String?) -> Dry::Monads::Result[Hash[Symbol, untyped]]
24
+ def call(conversation_id:, body:, module_type: nil)
25
+ argument! conversation_id: :required, fatal: true if conversation_id.blank?
26
+ argument! body: :required, fatal: true if body.blank?
27
+ argument! module_type: :invalid, fatal: true if !module_type.nil? && module_type.blank?
28
+
29
+ # On `platform` Guesty picks the booking channel, or email, and echoes the channel it used.
30
+ response = Guesty.do_request(
31
+ :post,
32
+ "communication/conversations/#{conversation_id}/send-message",
33
+ { module: { type: module_type || 'platform' }, body: },
34
+ retries: 0
35
+ )
36
+ return response unless response.success?
37
+
38
+ Success(normalize(response.value!.body.fetch(:data)))
39
+ end
40
+
41
+ private
42
+
43
+ def normalize(post)
44
+ {
45
+ message_id: post.fetch(:_id),
46
+ conversation_id: post.fetch(:conversationId),
47
+ module_type: post.dig(:module, :type),
48
+ body: post.fetch(:body),
49
+ created_at: Time.iso8601(post.fetch(:createdAt))
50
+ }
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,121 @@
1
+ module Guesty
2
+ module Conversations
3
+ module Posts
4
+ class Index
5
+ include Guesty::Operation
6
+
7
+ LIMIT = 500
8
+ LOG_SENDER = 'log'.freeze #: ::String
9
+ SENDERS = ['guest', 'host', LOG_SENDER].freeze #: ::Array[::String]
10
+ SYSTEM_MODULE_TYPES = %w[note aiSummary].freeze #: ::Array[::String]
11
+ INVALID_RESPONSE = 'Guesty posts response has an unexpected shape.'.freeze #: ::String
12
+
13
+ # @rbs (conversation_id: ::String, ?cursor: ::String?) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
14
+ def call(conversation_id:, cursor: nil)
15
+ argument! conversation_id: :required if conversation_id.blank?
16
+
17
+ response = Guesty.do_request(
18
+ :get,
19
+ "communication/conversations/#{conversation_id}/posts",
20
+ query(cursor)
21
+ )
22
+ return response unless response.success?
23
+
24
+ Success(normalize_response(response.value!.body[:data]))
25
+ end
26
+
27
+ private
28
+
29
+ # @rbs (untyped data) -> ::Hash[::Symbol, untyped]
30
+ def normalize_response(data)
31
+ raw_posts = data.is_a?(Hash) ? data[:posts] : nil #: ::Array[untyped]?
32
+ error! INVALID_RESPONSE unless raw_posts.is_a?(Array)
33
+
34
+ posts = raw_posts.filter_map { |post| normalize_post(post) }
35
+ { results: posts, cursor_after: data.dig(:cursor, :after).presence, oldest_created_at: oldest_created_at(raw_posts) }
36
+ end
37
+
38
+ # Taken before the filter. A page of only skipped post types must still tell the caller
39
+ # how far back it reached.
40
+ # @rbs (::Array[untyped] raw_posts) -> ::Time?
41
+ def oldest_created_at(raw_posts)
42
+ raw_posts.filter_map { |post| Time.iso8601(post[:createdAt]) if post[:createdAt].present? }.min
43
+ end
44
+
45
+ def query(cursor)
46
+ params = { limit: LIMIT, sort: '-createdAt' }
47
+ params[:cursorAfter] = cursor if cursor.present?
48
+ params
49
+ end
50
+
51
+ def normalize_attachments(attachments)
52
+ attachments.map { |attachment| attachment_media_type(attachment[:type]) }
53
+ end
54
+
55
+ # Parallel to #normalize_attachments. Index N names the same attachment in both arrays,
56
+ # so a missing attachmentUrl keeps its slot as an empty string.
57
+ def normalize_attachment_keys(attachments)
58
+ attachments.map { |attachment| attachment[:attachmentUrl].to_s }
59
+ end
60
+
61
+ def attachment_media_type(type)
62
+ return 'application/octet-stream' unless type.is_a?(String) && type.present?
63
+ return type if type.include?('/')
64
+
65
+ MiniMime.lookup_by_extension(type)&.content_type || 'application/octet-stream'
66
+ end
67
+
68
+ # Guesty allows a post that is only an attachment. A post with neither text nor a file
69
+ # carries nothing. Provider-side automation posts stay visible so the host does not answer a
70
+ # turn that was already answered.
71
+ def importable?(post, attachments)
72
+ return false if post[:_id].blank? || post[:createdAt].blank?
73
+ return false if SYSTEM_MODULE_TYPES.include?(post.dig(:module, :type))
74
+
75
+ SENDERS.include?(post[:sentBy]) && (post[:body].present? || attachments.any?)
76
+ end
77
+
78
+ # provider_reservation_id routes the post to its stay and author_name lands on the sync row; neither is a message column.
79
+ # @rbs (untyped post) -> untyped
80
+ def normalize_post(post)
81
+ raw_attachments = post.fetch(:attachments, [])
82
+ attachments = normalize_attachments(raw_attachments)
83
+ return unless importable?(post, attachments)
84
+
85
+ {
86
+ provider_message_id: post[:_id],
87
+ sender_kind: sender_kind(post),
88
+ body: post[:body] || '',
89
+ attachments:,
90
+ attachment_keys: normalize_attachment_keys(raw_attachments),
91
+ module_type: channel_type(post),
92
+ provider_reservation_id: post.dig(:module, :reservationId),
93
+ provider_occurred_at: Time.iso8601(post[:createdAt]),
94
+ author_name: author_name(post)
95
+ }
96
+ end
97
+
98
+ # Guesty's own scheduled posts and reservation logs are system posts. Everything else keeps the sender
99
+ # Guesty names.
100
+ # @rbs (untyped post) -> String
101
+ def sender_kind(post)
102
+ post[:isAutomatic] || post[:sentBy] == LOG_SENDER ? 'system' : post[:sentBy]
103
+ end
104
+
105
+ # @rbs (untyped post) -> String?
106
+ def channel_type(post)
107
+ post.dig(:module, :type) unless post[:sentBy] == LOG_SENDER
108
+ end
109
+
110
+ # Only a person answering from Guesty has a name worth keeping.
111
+ # @rbs (untyped post) -> String?
112
+ def author_name(post)
113
+ return if post[:isAutomatic] || post[:sentBy] != 'host'
114
+
115
+ name = post.dig(:from, :fullName)
116
+ name.presence if name.is_a?(String)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,37 @@
1
+ module Guesty
2
+ module Conversations
3
+ module Posts
4
+ # Walks a thread newest-first until the page that reaches `since`, or to the end without one.
5
+ class Since
6
+ include Guesty::Operation
7
+
8
+ MAX_PAGES = 20
9
+
10
+ # @rbs (conversation_id: ::String, ?since: ::Time?) -> Dry::Monads::Result[::Array[untyped]]
11
+ def call(conversation_id:, since: nil)
12
+ argument! conversation_id: :required if conversation_id.blank?
13
+
14
+ posts = [] #: ::Array[untyped]
15
+ cursor = nil #: ::String?
16
+
17
+ MAX_PAGES.times do
18
+ page = Index.new.call!(conversation_id:, cursor:).value!
19
+ posts.concat(page.fetch(:results))
20
+
21
+ cursor = page.fetch(:cursor_after)
22
+ return Success(posts) if cursor.nil? || reached?(since, page.fetch(:oldest_created_at))
23
+ end
24
+
25
+ error! "Guesty conversation #{conversation_id} pagination exceeded #{MAX_PAGES} pages", fatal: true
26
+ end
27
+
28
+ private
29
+
30
+ # @rbs (::Time? since, ::Time? oldest_created_at) -> bool
31
+ def reached?(since, oldest_created_at)
32
+ since.present? && oldest_created_at.present? && oldest_created_at <= since
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,31 @@
1
+ module Guesty
2
+ module Conversations
3
+ class Search
4
+ include Guesty::Operation
5
+
6
+ FIELDS = '_id meta.reservations._id meta.reservations.status'.freeze #: ::String
7
+
8
+ # @rbs (external_id: ::String) -> Dry::Monads::Result[::Hash[::Symbol, untyped]?]
9
+ def call(external_id:)
10
+ argument! external_id: :required if external_id.blank?
11
+
12
+ response = Guesty.do_request(:get, 'communication/conversations', query(external_id))
13
+ return response unless response.success?
14
+
15
+ conversation = response.value!.body[:data][:conversations].first
16
+
17
+ Success(conversation && { id: conversation.fetch(:_id), reservations: Guesty::Reservations.from_conversation(conversation) })
18
+ end
19
+
20
+ private
21
+
22
+ def query(external_id)
23
+ {
24
+ fields: FIELDS,
25
+ filters: [{ field: 'reservation._id', operator: '$eq', value: external_id }].to_json,
26
+ limit: 1
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,25 @@
1
+ module Guesty
2
+ module Conversations
3
+ class Show
4
+ include Guesty::Operation
5
+
6
+ FIELDS = Search::FIELDS #: ::String
7
+
8
+ # @rbs (conversation_id: ::String) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
9
+ def call(conversation_id:)
10
+ argument! conversation_id: :required if conversation_id.blank?
11
+
12
+ response = Guesty.do_request(
13
+ :get,
14
+ "communication/conversations/#{conversation_id}",
15
+ { fields: FIELDS }
16
+ )
17
+ return response unless response.success?
18
+
19
+ conversation = response.value!.body[:data]
20
+
21
+ Success({ id: conversation.fetch(:_id), reservations: Guesty::Reservations.from_conversation(conversation) })
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ module Guesty
2
+ module Conversations
3
+ # Neutral channel names for Guesty's versioned module types.
4
+ CHANNELS = {
5
+ 'airbnb2' => 'airbnb',
6
+ 'bookingCom' => 'booking_com'
7
+ }.freeze #: ::Hash[::String, ::String]
8
+ VERSION_SUFFIX = /\d+\z/ #: ::Regexp
9
+
10
+ # @rbs (::String module_type) -> ::String
11
+ def self.channel(module_type)
12
+ CHANNELS.fetch(module_type) { module_type.sub(VERSION_SUFFIX, '') }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,32 @@
1
+ module Guesty
2
+ class Error < StandardError
3
+ attr_reader :fatal
4
+
5
+ # @rbs (?::String message, ?fatal: bool) -> void
6
+ def initialize(message = nil, fatal: false)
7
+ @fatal = fatal
8
+
9
+ super(message)
10
+ end
11
+ end
12
+
13
+ # Raised by operations before any network call when the arguments are unusable.
14
+ # fatal: true marks a refusal no retry can fix; the host decides what to do with it.
15
+ class ArgumentError < Error; end
16
+
17
+ # Raised by the rate limiter middleware when the injected limiter refuses the
18
+ # request: the call never left the process, so no write landed upstream.
19
+ class ThrottledError < Error
20
+ attr_reader :key, :threshold, :interval, :retry_after
21
+
22
+ # @rbs (key: ::Symbol, threshold: ::Integer, interval: untyped, retry_after: untyped) -> void
23
+ def initialize(key:, threshold:, interval:, retry_after:)
24
+ @key = key
25
+ @threshold = threshold
26
+ @interval = interval
27
+ @retry_after = retry_after
28
+
29
+ super("Rate limit exceeded for #{key}")
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,61 @@
1
+ module Guesty
2
+ module GuestFolio
3
+ module InvoiceItems
4
+ # Closed Airbnb Resolution Center items: negative amounts are refunds and
5
+ # positive amounts are guest charges.
6
+ class Index
7
+ include Guesty::Operation
8
+
9
+ FIELDS = %w[normalType totalPrice logs.createdAt].freeze #: ::Array[::String]
10
+ INVALID_RESPONSE = 'Guesty invoice items response has an unexpected shape.'.freeze #: ::String
11
+
12
+ # @rbs (reservation_id: ::String) -> Dry::Monads::Result[::Array[untyped]]
13
+ def call(reservation_id:)
14
+ argument! reservation_id: :required if reservation_id.blank?
15
+
16
+ response = Guesty.do_request(:get, 'guest-folio/invoice-items', query(reservation_id))
17
+ return response unless response.success?
18
+
19
+ Success(normalize_response(response.value!.body, reservation_id))
20
+ end
21
+
22
+ private
23
+
24
+ # @rbs (::String reservation_id) -> ::Hash[::Symbol, ::String]
25
+ def query(reservation_id)
26
+ {
27
+ reservationIds: reservation_id,
28
+ fields: FIELDS.join(',')
29
+ }
30
+ end
31
+
32
+ # @rbs (untyped body, ::String reservation_id) -> ::Array[untyped]
33
+ def normalize_response(body, reservation_id)
34
+ results = body[:results] if body.is_a?(Hash)
35
+ error! INVALID_RESPONSE unless results.is_a?(Array)
36
+
37
+ result = results.find { |row| row[:reservationId] == reservation_id }
38
+ return [] if result.nil?
39
+
40
+ items = result[:invoiceItems]
41
+ error! INVALID_RESPONSE unless items.is_a?(Array) && result[:currency].present?
42
+
43
+ items.map { |item| normalize_item(item, result.fetch(:currency)) }
44
+ end
45
+
46
+ # @rbs (untyped item, ::String currency) -> untyped
47
+ def normalize_item(item, currency)
48
+ created_at = item.dig(:logs, :createdAt)
49
+
50
+ {
51
+ external_id: item.fetch(:id),
52
+ normal_type: item[:normalType],
53
+ amount: item[:totalPrice]&.to_d,
54
+ currency:,
55
+ occurred_at: (Time.iso8601(created_at) if created_at.present?)
56
+ }
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,25 @@
1
+ module Guesty
2
+ module Guests
3
+ module Profiles
4
+ class Create
5
+ include Guesty::Operation
6
+
7
+ REQUIRED_ATTRIBUTES = %i[firstName lastName].freeze #: ::Array[::Symbol]
8
+
9
+ # @rbs (attributes: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
10
+ def call(attributes:)
11
+ argument! attributes: :required, fatal: true if attributes.blank?
12
+
13
+ missing = REQUIRED_ATTRIBUTES.find { |attribute| attributes[attribute].blank? }
14
+ argument! missing => :required if missing
15
+
16
+ response = Guesty.do_request :post, 'guests-crud', attributes, retries: 0
17
+
18
+ raise response.failure unless response.success?
19
+
20
+ Success(response.value!.body)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,23 @@
1
+ module Guesty
2
+ module Guests
3
+ module Profiles
4
+ class Show
5
+ include Guesty::Operation
6
+
7
+ # @rbs (guest_id: ::String, ?fields: ::Array[::String]?) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
8
+ def call(guest_id:, fields: nil)
9
+ argument! guest_id: :required if guest_id.blank?
10
+
11
+ attributes = {} #: ::Hash[::Symbol, ::String]
12
+ attributes[:fields] = fields.join(' ') if fields.present?
13
+
14
+ response = Guesty.do_request :get, "guests-crud/#{guest_id}", attributes
15
+
16
+ raise response.failure unless response.success?
17
+
18
+ Success(response.value!.body)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,36 @@
1
+ module Guesty
2
+ module Guests
3
+ module Profiles
4
+ class Update
5
+ include Guesty::Operation
6
+
7
+ # @rbs (guest_id: ::String, attributes: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
8
+ def call(guest_id:, attributes:)
9
+ argument! guest_id: :required if guest_id.blank?
10
+ argument! attributes: :required, fatal: true if attributes.blank?
11
+
12
+ response = Guesty.do_request :put, "guests-crud/#{guest_id}", prepare_tags(guest_id, attributes)
13
+
14
+ raise response.failure unless response.success?
15
+
16
+ Success(response.value!.body)
17
+ end
18
+
19
+ private
20
+
21
+ # Actions on a single tag are not supported by Guesty.
22
+ # Instead, we fetch all tags and upload a modified array.
23
+ def prepare_tags(guest_id, attributes)
24
+ tags_to_add = attributes[:addTags] || []
25
+ tags_to_remove = attributes[:removeTags] || []
26
+ payload = attributes.except(:addTags, :removeTags)
27
+
28
+ return payload if tags_to_add.blank? && tags_to_remove.blank?
29
+
30
+ current_profile = Show.new.call!(guest_id:, fields: ['tags']).value!
31
+ payload.merge(tags: (current_profile[:tags] | tags_to_add) - tags_to_remove)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,5 @@
1
+ module Guesty
2
+ module Guests
3
+ OTA_LINK_TYPES = { airbnb: 'airbnb' }.freeze
4
+ end
5
+ end
@@ -0,0 +1,134 @@
1
+ module Guesty
2
+ module Homes
3
+ module Calendars
4
+ class Show
5
+ include Guesty::Operation
6
+
7
+ BLOCK_TYPE_MAPPING = {
8
+ m: :manual_block,
9
+ r: :reserved_date,
10
+ b: :booked_reservation,
11
+ bd: :blocked_by_default,
12
+ sr: :smart_calendar_rule,
13
+ abl: :annual_booking_limit,
14
+ a: :allotment_block,
15
+ bw: :booking_window,
16
+ o: :owner_reservation,
17
+ pt: :preparation_time,
18
+ ic: :ical_import,
19
+ an: :advance_notice,
20
+ cio: :check_in_out
21
+ }.freeze
22
+
23
+ # @rbs (home_id: ::String, start_date: ::Date, end_date: ::Date) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
24
+ def call(home_id:, start_date:, end_date:)
25
+ argument! home_id: :required if home_id.blank?
26
+ argument! end_date: :invalid if end_date <= start_date
27
+
28
+ response = fetch_calendar_data(home_id, start_date, end_date)
29
+
30
+ raise response.failure if response.failure?
31
+
32
+ Success(normalize_calendar(response.value!.body))
33
+ end
34
+
35
+ private
36
+
37
+ def fetch_calendar_data(home_id, start_date, end_date)
38
+ params = {
39
+ startDate: start_date,
40
+ endDate: end_date,
41
+ view: 'full'
42
+ }
43
+
44
+ Guesty.do_request :get, "availability-pricing/api/calendar/listings/minified/#{home_id}", params
45
+ end
46
+
47
+ def normalize_calendar(body)
48
+ calendar = body.dig(:data, :days) || {}
49
+ @blocks = calendar[:blocks] || {}
50
+ @days = calendar[:calendar] || []
51
+ available, unavailable = @days.partition { |day| available_day?(day) }
52
+
53
+ validate_currency(calendar[:currency], @days)
54
+
55
+ {
56
+ available: attrs_for_available(available),
57
+ unavailable: attrs_for_unavailable(unavailable),
58
+ blocks: attrs_for_blocks
59
+ }
60
+ end
61
+
62
+ # The host prices in one currency; a listing in another would silently
63
+ # misprice. Skipped when the host has not configured one.
64
+ # @rbs (::String? currency, ::Array[::Hash[::Symbol, untyped]] days) -> void
65
+ def validate_currency(currency, days)
66
+ return nil if days.empty? || Guesty.config.default_currency.nil?
67
+ return nil if currency.to_s.upcase == Guesty.config.default_currency
68
+
69
+ error! "unsupported currency #{currency}"
70
+ end
71
+
72
+ # @rbs (::Hash[::Symbol, untyped] day) -> bool
73
+ def available_day?(day)
74
+ day[:blocks].empty? || day_is_available_for_checkout?(day)
75
+ end
76
+
77
+ # @rbs (::Hash[::Symbol, untyped] day) -> bool
78
+ def day_is_available_for_checkout?(day)
79
+ return false if day[:blocks].except(:b).any?
80
+ return false if previous_night_blocked?(day)
81
+
82
+ day[:blockIds].any? { |id| @blocks.dig(id.to_sym, :reservation, :checkInDateLocalized) == day[:date] }
83
+ end
84
+
85
+ # @rbs (::Hash[::Symbol, untyped] day) -> bool
86
+ def previous_night_blocked?(day)
87
+ blocked_dates.include?(day[:date].to_date.prev_day.iso8601)
88
+ end
89
+
90
+ # @rbs () -> ::Set[::String]
91
+ def blocked_dates
92
+ @blocked_dates ||= @days.reject { |day| day[:blocks].empty? }.to_set { |day| day[:date] }
93
+ end
94
+
95
+ # @rbs (::Array[::Hash[::Symbol, untyped]] days) -> ::Array[::Hash[::Symbol, untyped]]
96
+ def attrs_for_available(days)
97
+ days.map do |day|
98
+ {
99
+ date: day[:date],
100
+ price: day[:price],
101
+ min_nights: day[:minNights],
102
+ check_in: day[:blocks].empty?
103
+ }
104
+ end
105
+ end
106
+
107
+ # @rbs (::Array[::Hash[::Symbol, untyped]] days) -> ::Array[::Hash[::Symbol, untyped]]
108
+ def attrs_for_unavailable(days)
109
+ days.map do |day|
110
+ {
111
+ date: day[:date],
112
+ price: day[:price],
113
+ min_nights: day[:minNights],
114
+ check_in: false,
115
+ blocked_as: day[:blocks].keys.filter_map { |key| BLOCK_TYPE_MAPPING[key] }
116
+ }
117
+ end
118
+ end
119
+
120
+ # @rbs () -> ::Array[::Hash[::Symbol, untyped]]
121
+ def attrs_for_blocks
122
+ @blocks.values.map do |block|
123
+ {
124
+ kind: BLOCK_TYPE_MAPPING[block[:type].to_sym],
125
+ start_date: block[:startDate].to_date,
126
+ end_date: block[:endDate].to_date,
127
+ comment: block[:note].presence
128
+ }
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,46 @@
1
+ module Guesty
2
+ module Homes
3
+ # `active` is a filter, so all listings take two passes. Tags come back as
4
+ # ids on the listing; tag names live on GET listings/tags.
5
+ class Search
6
+ include Guesty::Operation
7
+
8
+ # @example
9
+ # attributes: {
10
+ # params: { ids: 'guestyID', available: { checkIn: "YYYY-MM-DD", checkOut: "YYYY-MM-DD", minOccupancy: 3 }.to_json }
11
+ # filters: [{ operator: '$eq', field: 'type', value: 'MTL_CHILD' }],
12
+ # response_keys: %w[
13
+ # _id
14
+ # nickname
15
+ # type
16
+ # ]
17
+ # }
18
+
19
+ # @rbs (attributes: ::Hash[::Symbol, untyped], ?skip: ::Integer, ?take: ::Integer) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
20
+ def call(attributes:, skip: 0, take: 100)
21
+ argument! take: :invalid if take.blank? || take <= 0
22
+
23
+ response = Guesty.do_request :get, 'listings', query(attributes, skip, take)
24
+
25
+ raise response.failure unless response.success?
26
+
27
+ body = response.value!.body
28
+
29
+ Success(body.slice(:results, :count))
30
+ end
31
+
32
+ private
33
+
34
+ def query(attributes, skip, take)
35
+ {
36
+ active: true,
37
+ fields: (attributes[:response_keys] || []).join(' '),
38
+ limit: take,
39
+ skip:,
40
+ **(attributes[:filters].present? ? { filters: attributes[:filters].to_json } : {}),
41
+ **(attributes[:params] || {})
42
+ }.compact_blank
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,28 @@
1
+ module Guesty
2
+ # The default rate limiter: in-memory fixed windows per key. Per-process only;
3
+ # a host running several processes injects a shared backend, such as Redis,
4
+ # via Guesty.config.rate_limiter.
5
+ class MemoryRateLimiter
6
+ def initialize
7
+ @windows = {}
8
+ end
9
+
10
+ # @rbs (key: ::Symbol, threshold: ::Integer, interval: untyped) -> void
11
+ def throttle!(key:, threshold:, interval:)
12
+ now = Time.now
13
+ window_start, count = @windows.fetch(key, [now, 0])
14
+
15
+ if now >= window_start + interval
16
+ window_start = now
17
+ count = 0
18
+ end
19
+
20
+ count += 1
21
+ @windows[key] = [window_start, count]
22
+
23
+ return if count <= threshold
24
+
25
+ raise ThrottledError.new(key:, threshold:, interval:, retry_after: (window_start + interval) - now)
26
+ end
27
+ end
28
+ end