digiwin_dsp 0.2.4 → 0.3.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: feebd7c9c5d89e9d6306b00ecccb402151c9e299201ecf3b98cfbf7c9a1590a1
4
- data.tar.gz: 7c9fbb5aa913649c417a4f23caf6bf9f3b10bc92b6c2efb34e9cc1cdce8808ad
3
+ metadata.gz: 3cfde42cbd2b4448e9c5e4927e9ecb65f61ae19a46474154ae1349d73cd2461c
4
+ data.tar.gz: 6d35ad2fd9430c5440f626b15f3c98c9a8dd627c2732bb349bc355f6693d9de3
5
5
  SHA512:
6
- metadata.gz: 292e2d148f9af342959a826cbed33999cbd81b169719fa79882b309bfdfb6b1800e88ed18dfcd3d0210784d85f7df9dea1e02e63b0a0f9e67e276e235c7c403d
7
- data.tar.gz: 437cd4412d96aa9914b99cea75ca5ce06274870aada7a2462b53d64e18eb5ebb03b9dbee07bd78cadab08880652aabe49e7ce88ff987c043be7530ff031c3712
6
+ metadata.gz: 95d2787c766fa0a316542b37d0b5620738e9dcff687562875698e5c1d0f3a3ffaaa2e93a81f24b34e0f6e27047a9c53d22e1368725879f36a6f48116b02f2b7c
7
+ data.tar.gz: c8f1b7781c06e3fa745d4e2a6770d7442dd443a38e10ca4ce6767b5aaa82bbf1016ef8aeb961922ebe9f41321d6702be561a1c82810223335a0a340c6f6eb27b
data/CHANGELOG.md CHANGED
@@ -6,6 +6,53 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.1] - 2026-06-12
10
+
11
+ Hardening patch from a full gem + docs review. All fixes grounded in the vendor YAML specs.
12
+
13
+ ### Fixed
14
+
15
+ - **TLS failures now raise `DigiwinDsp::NetworkError`.** `Faraday::SSLError` sits directly under `Faraday::Error` (not `ConnectionFailed`), so certificate problems previously leaked as raw Faraday exceptions that `rescue DigiwinDsp::Error` could not catch.
16
+ - **Three more DSP failure messages classify into typed exceptions** instead of falling through to generic `Error`:
17
+ - `Shipped:訂單已出貨,不可取消` (DSPOOFFICIAL002) → `ValidationError` — permanent; the order left the warehouse
18
+ - `Processing:新增訂單處理中,不可取消` (DSPOOFFICIAL002) → `RateLimitError` — retryable once ERP processes the add
19
+ - `SalesNotCreate:銷貨單未成立` (DSPOOFFICIAL004) → `RateLimitError` — retryable once ERP converts the sales doc
20
+ Sidekiq/ActiveJob retry strategies can now distinguish "don't retry" from "retry later" on cancel and invoice flows.
21
+ - **`Webhooks::Event.parse_json` / `.extract_request` are now private class methods.** They were internal helpers accidentally exposed on all three event classes.
22
+ - **`WebhookSubscription` rejects non-`https://` callback addresses** at registration time. DSPOOFFICIAL100 mandates HTTPS callbacks, and with no HMAC signing a plaintext callback would be indefensible. (Previously the README claimed this was enforced when it wasn't.)
23
+
24
+ ### Docs
25
+
26
+ - `dsp-api-spec.md`: corrected the e-invoice carrier field name — `Resources::Invoice` sends **`carrier_type`** (DSPOOFFICIAL004:164), not `carrier_code`. `carrier_code` is only on `Resources::Order` and the inbound webhook. Optional fields aren't validated client-side, so the wrong name silently drops carrier data.
27
+ - `dsp-api-spec.md`: failure-message table now covers 002 (cancel) and 004 (invoice) sources, not just 001.
28
+ - README: webhook security bullet now describes the actual https enforcement.
29
+
30
+ ## [0.3.0] - 2026-05-28
31
+
32
+ DSP webhook support (DSPOOFFICIAL100). Lets a Rails app register a callback URL with DSP, then receive and parse the three documented ERP-originated push events: inventory updates, shipping/logistics status, and invoice issuance.
33
+
34
+ ### Added
35
+
36
+ - **`DigiwinDsp::Resources::WebhookSubscription`** — outbound wrapper for `POST /v1/webhook` on the new webhook base path. Registers a callback URL for one of three actions (`product/inventory_update` / `wms/logistics/package/update` / `invoice/update`). Validates locally: known action, non-empty address, ≤500 chars per the spec.
37
+ - **`DigiwinDsp::Webhooks` module** with three inbound event parsers:
38
+ - `Webhooks::InventoryUpdate` — exposes `prod`, `platform_id`, `sale_page_id`, `spec_list`
39
+ - `Webhooks::LogisticsUpdate` — exposes `form_no`, `func_name`, `status_date`, `status_time`, `tracking_number`, `distributor_code`, `message`
40
+ - `Webhooks::InvoiceUpdate` — exposes `invoices` (Array; DSP batches multiple invoices per push)
41
+ - `Webhooks.parse(raw_body, action:)` dispatcher routes by action string
42
+ - `Webhooks::ParseError < ValidationError` for malformed JSON / envelope / unknown action
43
+ - **`Configuration#webhook_base_url`** — separate base path for DSPOOFFICIAL100 (`/api/webhook` vs the SalesOrder `/api/DSP`). Defaults resolve from `environment`; explicit override via `DIGIWIN_DSP_WEBHOOK_BASE_URL` env var. Same HTTPS + `allowed_hosts` guard as `base_url`.
44
+ - **Client `base_url:` kwarg override** so the new `WebhookSubscription` can target the webhook base path without leaking the routing into `Configuration#base_url`.
45
+ - **Dual-envelope detection in `Client#inspect_envelope`** — handles both `{Status, Message, response_detail[]}` (SalesOrder family) and `{srvver, std_data: {execution, response}}` (webhook family). Both run through the same `ENVELOPE_FAILURE_MAP` regex classifier, so `WrongStatus:`, `系統異常:`, `Duplicated:`, etc. produce the right typed exception regardless of endpoint.
46
+ - `docs/dsp-specs/DSPOOFFICIAL100.yaml` — the OpenAPI spec used to drive the implementation.
47
+
48
+ ### Security
49
+
50
+ - ⚠️ **DSP does not sign inbound webhooks.** No HMAC header documented. README calls out defense-in-depth: HTTPS-only callback URL, unguessable path, optional IP allowlist, 200-within-30s reply, caller-side idempotency by `form_no` / `invoice_number`.
51
+
52
+ ### Changed
53
+
54
+ - Removed dead `Client#envelope_error_attrs` helper (replaced by `classify_envelope_failure` which serves both envelope shapes).
55
+
9
56
  ## [0.2.4] - 2026-05-22
10
57
 
11
58
  ### Added
@@ -179,7 +226,9 @@ Initial release. Covers the four Self-hosted Website Module (自有官網模組)
179
226
  - The gem is **synchronous on purpose**. Callers wrap requests in their own background job runner (e.g. ActiveJob) when needed.
180
227
  - Idempotency: clients can send `X-Idempotency-Key` via the `idempotency_key:` kwarg. DSP also dedupes server-side by `form_no + platform_id`.
181
228
 
182
- [Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.4...HEAD
229
+ [Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.3.1...HEAD
230
+ [0.3.1]: https://github.com/7a6163/digiwin_dsp/compare/v0.3.0...v0.3.1
231
+ [0.3.0]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.4...v0.3.0
183
232
  [0.2.4]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.3...v0.2.4
184
233
  [0.2.3]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.2...v0.2.3
185
234
  [0.2.2]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.1...v0.2.2
data/README.md CHANGED
@@ -13,6 +13,8 @@ Ruby client for the Digiwin DSP Self-hosted Website Module (自有官網模組)
13
13
  | Cancel order | `DigiwinDsp::Resources::Cancellation` | `POST /v1/SalesOrder/cancel` |
14
14
  | Invoice update | `DigiwinDsp::Resources::Invoice` | `POST /v1/SalesOrder/invoice` |
15
15
  | Return | `DigiwinDsp::Resources::Return` | `POST /v1/SalesOrder/return` |
16
+ | Register webhook | `DigiwinDsp::Resources::WebhookSubscription` | `POST /v1/webhook` (on webhook_base_url) |
17
+ | Receive webhook (inbound) | `DigiwinDsp::Webhooks.parse` | — |
16
18
 
17
19
  See [`docs/dsp-api-spec.md`](./docs/dsp-api-spec.md) (plus `docs/dsp-specs/*.yaml`) for the wire spec.
18
20
 
@@ -53,6 +55,7 @@ Every setting also falls back to an ENV var:
53
55
  | `platform_id` | `DIGIWIN_DSP_PLATFORM_ID` | `nil` | sent **per-record** in `request_detail.platform_id` (not in auth headers) |
54
56
  | `environment` | `DIGIWIN_DSP_ENV` | `:sandbox` | `:sandbox` (UAT) or `:production` |
55
57
  | `base_url` | `DIGIWIN_DSP_BASE_URL` | resolved from `environment` | must be `https://` and have a host in `allowed_hosts` |
58
+ | `webhook_base_url` | `DIGIWIN_DSP_WEBHOOK_BASE_URL` | resolved from `environment` | for `WebhookSubscription`; same validation as `base_url` |
56
59
  | `allowed_hosts` | — | `["digiwindsp.digiwin.com"]` | SSRF allowlist; extend if you proxy DSP through a different domain |
57
60
  | `timeout` | — | `10` | seconds |
58
61
  | `open_timeout` | — | `5` | seconds |
@@ -154,12 +157,68 @@ Each endpoint requires a specific `order_status` value inside `request_detail`.
154
157
 
155
158
  ### Idempotency
156
159
 
157
- Pass `idempotency_key:` to attach an `X-Idempotency-Key` request header. DSP also dedupes server-side by `form_no + platform_id` and returns `Duplicated:訂單不可重複` on a re-send (mapped to `DuplicateRequestError`).
160
+ **DSP only dedupes on `form_no + platform_id`.** Use a deterministic `form_no` derived from your domain order ID and DSP will reject duplicates with `Duplicated:訂單不可重複` (mapped to `DuplicateRequestError`).
161
+
162
+ The `idempotency_key:` kwarg attaches an `X-Idempotency-Key` request header for logging/tracing on your side, but **DSP UAT does not act on it** (live-verified 2026-05-22 with two distinct `form_no` POSTs sharing the same key — both succeeded). Treat the header as a trace ID, not an idempotency guarantee.
158
163
 
159
164
  ```ruby
160
165
  DigiwinDsp::Resources::Order.create(record, idempotency_key: "order-#{record['form_no']}")
161
166
  ```
162
167
 
168
+ ### Webhooks (DSP push events)
169
+
170
+ Two halves — register a callback URL with DSP, then receive ERP-originated events at that URL.
171
+
172
+ **Register (outbound)** — tell DSP where to push notifications for one of three documented actions:
173
+
174
+ ```ruby
175
+ DigiwinDsp::Resources::WebhookSubscription.create(
176
+ action: "product/inventory_update", # or "wms/logistics/package/update", "invoice/update"
177
+ address: "https://yourshop.example.com/webhooks/dsp/inventory"
178
+ )
179
+ # => { "platform_id" => ..., "address" => ..., "action" => ... }
180
+ ```
181
+
182
+ `platform_id` falls back to `Configuration#platform_id`; `prod` defaults to `"OFFICIALWEBSITE"`. Each action needs its own subscription call.
183
+
184
+ **Receive (inbound)** — parse what DSP POSTs to your callback URL:
185
+
186
+ ```ruby
187
+ # config/routes.rb
188
+ post "/webhooks/dsp/inventory", to: "dsp_webhooks#inventory"
189
+
190
+ # app/controllers/dsp_webhooks_controller.rb
191
+ class DspWebhooksController < ActionController::API
192
+ def inventory
193
+ event = DigiwinDsp::Webhooks::InventoryUpdate.parse(request.raw_post)
194
+ # event.platform_id, event.sale_page_id, event.spec_list[] — see docs/dsp-specs/DSPOOFFICIAL100.yaml
195
+ DspInventoryUpdateJob.perform_later(event.raw)
196
+ head :ok
197
+ rescue DigiwinDsp::Webhooks::ParseError => e
198
+ Rails.logger.error("DSP webhook parse failed: #{e.dsp_message || e.message}")
199
+ head :bad_request
200
+ end
201
+ end
202
+ ```
203
+
204
+ Or use the dispatcher when one URL handles all 3 actions:
205
+
206
+ ```ruby
207
+ event = DigiwinDsp::Webhooks.parse(request.raw_post, action: params[:action])
208
+ case event
209
+ when DigiwinDsp::Webhooks::InventoryUpdate then ...
210
+ when DigiwinDsp::Webhooks::LogisticsUpdate then event.tracking_number
211
+ when DigiwinDsp::Webhooks::InvoiceUpdate then event.invoices.each { |inv| ... }
212
+ end
213
+ ```
214
+
215
+ > ⚠️ **DSP does NOT sign inbound webhooks.** There is no HMAC header. Defend the callback endpoint with:
216
+ > - HTTPS-only — `WebhookSubscription` rejects non-`https://` addresses at registration time (DSP's own spec mandates HTTPS callbacks)
217
+ > - An unguessable URL path (treat it as a secret)
218
+ > - An IP allowlist for DSP's egress range if your network team can get one
219
+ > - Replying `200 OK` within 30 seconds (DSP will retry and may eventually block your endpoint if too many calls fail)
220
+ > - **Idempotency by `form_no` / `invoice_number` / etc. on your side** — DSP may retry the same event
221
+
163
222
  ### Background jobs
164
223
 
165
224
  The gem is synchronous on purpose. Wrap calls in your own job runner:
@@ -33,13 +33,17 @@ module DigiwinDsp
33
33
  [/Duplicated:/, DuplicateRequestError], # order already exists
34
34
  [/Processing:資料處理中/, RateLimitError], # transient; retry later
35
35
  [/Processing:取消訂單處理中/, ValidationError], # cancel in flight
36
+ [/Processing:新增訂單處理中/, RateLimitError], # add in flight; cancel retryable after ERP processes (002)
37
+ [/Shipped:/, ValidationError], # already shipped — cancel permanently impossible (002)
38
+ [/SalesNotCreate:/, RateLimitError], # ERP hasn't converted sales doc yet; invoice retryable (004)
36
39
  [/WrongStatus:/, ValidationError], # bad payload
37
40
  [/系統異常:/, ServerError] # DSP internal error
38
41
  ].freeze
39
42
 
40
- def initialize(configuration: DigiwinDsp.configuration, authenticator: nil)
41
- @configuration = configuration
42
- @authenticator = authenticator || Authenticator.new(configuration)
43
+ def initialize(configuration: DigiwinDsp.configuration, authenticator: nil, base_url: nil)
44
+ @configuration = configuration
45
+ @authenticator = authenticator || Authenticator.new(configuration)
46
+ @base_url_override = base_url
43
47
  end
44
48
 
45
49
  def post(path, body, idempotency_key: nil, headers: {})
@@ -51,7 +55,10 @@ module DigiwinDsp
51
55
  req.body = body
52
56
  end
53
57
  handle_response(response)
54
- rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
58
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError => e
59
+ # SSLError sits directly under Faraday::Error (not ConnectionFailed),
60
+ # so it needs an explicit entry — otherwise TLS failures leak as raw
61
+ # Faraday exceptions that `rescue DigiwinDsp::Error` won't catch.
55
62
  raise NetworkError, e.message
56
63
  end
57
64
 
@@ -78,7 +85,7 @@ module DigiwinDsp
78
85
  end
79
86
 
80
87
  def connection_base_url
81
- base = @configuration.base_url
88
+ base = @base_url_override || @configuration.base_url
82
89
  base.end_with?("/") ? base : "#{base}/"
83
90
  end
84
91
 
@@ -108,11 +115,31 @@ module DigiwinDsp
108
115
  end
109
116
 
110
117
  def inspect_envelope(body)
111
- return body unless body.is_a?(Hash) && body["Status"].to_s.casecmp("failure").zero?
118
+ return body unless body.is_a?(Hash)
112
119
 
113
- message = body["Message"].to_s
120
+ failure = detect_envelope_failure(body)
121
+ return body unless failure
122
+
123
+ raise classify_envelope_failure(failure[:message], code: failure[:code], body: body)
124
+ end
125
+
126
+ # Detects either envelope shape and returns {message, code} if it's a
127
+ # failure response, or nil if it's a success / non-envelope body.
128
+ # - DSPOOFFICIAL100: { srvver, std_data: { execution: { code, description }, response } }
129
+ # - DSPOOFFICIAL001-005: { Status, Message, response_detail }
130
+ def detect_envelope_failure(body)
131
+ if (exec = body.dig("std_data", "execution"))
132
+ return nil if exec["code"].to_s == "0"
133
+
134
+ { message: exec["description"].to_s, code: exec["code"] }
135
+ elsif body["Status"].to_s.casecmp("failure").zero?
136
+ { message: body["Message"].to_s, code: body["Status"] }
137
+ end
138
+ end
139
+
140
+ def classify_envelope_failure(message, code:, body:)
114
141
  klass = ENVELOPE_FAILURE_MAP.find { |regex, _| regex.match?(message) }&.last || Error
115
- raise klass.new(message, **envelope_error_attrs(body))
142
+ klass.new(message, code: code, dsp_message: message, request_id: body["request_id"], http_status: 200)
116
143
  end
117
144
 
118
145
  def http_message(status, body)
@@ -130,15 +157,6 @@ module DigiwinDsp
130
157
  }
131
158
  end
132
159
 
133
- def envelope_error_attrs(body)
134
- {
135
- code: body["Status"],
136
- dsp_message: body["Message"],
137
- request_id: body["request_id"],
138
- http_status: 200
139
- }
140
- end
141
-
142
160
  # Block CRLF/LF/CR in header names and values. RFC 7230 §3.2.4 forbids
143
161
  # them and Faraday + Net::HTTP catch many forms, but not all — close
144
162
  # the gap to prevent header-injection / request-smuggling.
@@ -14,20 +14,28 @@ module DigiwinDsp
14
14
  production: "https://digiwindsp.digiwin.com/DSP/api/DSP"
15
15
  }.freeze
16
16
 
17
+ # Webhook-subscription endpoint (DSPOOFFICIAL100) lives at a separate
18
+ # base path from the SalesOrder endpoints.
19
+ WEBHOOK_BASE_URLS = {
20
+ sandbox: "https://digiwindsp.digiwin.com/DSP_UAT/api/webhook",
21
+ production: "https://digiwindsp.digiwin.com/DSP/api/webhook"
22
+ }.freeze
23
+
17
24
  attr_accessor :api_key, :api_secret, :platform_id, :environment, :logger,
18
25
  :timeout, :open_timeout, :allowed_hosts
19
- attr_writer :base_url
26
+ attr_writer :base_url, :webhook_base_url
20
27
 
21
28
  def initialize
22
- @api_key = ENV["DIGIWIN_DSP_API_KEY"]
23
- @api_secret = ENV["DIGIWIN_DSP_API_SECRET"]
24
- @platform_id = ENV["DIGIWIN_DSP_PLATFORM_ID"]
25
- @environment = ENV.fetch("DIGIWIN_DSP_ENV") { "sandbox" }.to_sym
26
- @base_url = ENV["DIGIWIN_DSP_BASE_URL"]
27
- @timeout = DEFAULT_TIMEOUT
28
- @open_timeout = DEFAULT_OPEN_TIMEOUT
29
- @logger = Logger.new(IO::NULL)
30
- @allowed_hosts = DEFAULT_ALLOWED_HOSTS.dup
29
+ @api_key = ENV["DIGIWIN_DSP_API_KEY"]
30
+ @api_secret = ENV["DIGIWIN_DSP_API_SECRET"]
31
+ @platform_id = ENV["DIGIWIN_DSP_PLATFORM_ID"]
32
+ @environment = ENV.fetch("DIGIWIN_DSP_ENV") { "sandbox" }.to_sym
33
+ @base_url = ENV["DIGIWIN_DSP_BASE_URL"]
34
+ @webhook_base_url = ENV["DIGIWIN_DSP_WEBHOOK_BASE_URL"]
35
+ @timeout = DEFAULT_TIMEOUT
36
+ @open_timeout = DEFAULT_OPEN_TIMEOUT
37
+ @logger = Logger.new(IO::NULL)
38
+ @allowed_hosts = DEFAULT_ALLOWED_HOSTS.dup
31
39
  end
32
40
 
33
41
  def base_url
@@ -36,6 +44,12 @@ module DigiwinDsp
36
44
  url
37
45
  end
38
46
 
47
+ def webhook_base_url
48
+ url = @webhook_base_url || resolve_default_webhook_base_url
49
+ validate_url!(url)
50
+ url
51
+ end
52
+
39
53
  def validate!
40
54
  return unless api_key.nil? || api_key.to_s.empty?
41
55
 
@@ -52,6 +66,13 @@ module DigiwinDsp
52
66
  end
53
67
  end
54
68
 
69
+ def resolve_default_webhook_base_url
70
+ WEBHOOK_BASE_URLS.fetch(environment) do
71
+ raise ConfigurationError,
72
+ "unknown environment #{environment.inspect}; expected one of #{WEBHOOK_BASE_URLS.keys.inspect}"
73
+ end
74
+ end
75
+
55
76
  def validate_url!(url)
56
77
  uri = URI.parse(url)
57
78
  raise ConfigurationError, "base_url must use https (got #{uri.scheme.inspect})" unless uri.scheme == "https"
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ module Resources
5
+ # Registers a webhook callback URL with DSP (DSPOOFFICIAL100,
6
+ # POST /v1/webhook on the webhook_base_url).
7
+ #
8
+ # Not a Resources::Base subclass — the request envelope is a single
9
+ # `request` object (not `request_detail[]` array), the fields are
10
+ # explicit kwargs (not an arbitrary hash), and it targets the
11
+ # webhook_base_url instead of base_url.
12
+ class WebhookSubscription
13
+ PATH = "/v1/webhook"
14
+ DEFAULT_PROD = "OFFICIALWEBSITE"
15
+ ACTIONS = %w[
16
+ product/inventory_update
17
+ wms/logistics/package/update
18
+ invoice/update
19
+ ].freeze
20
+ ADDRESS_MAX_LENGTH = 500
21
+
22
+ def self.create(action:, address:, platform_id: nil, prod: DEFAULT_PROD)
23
+ new.create(action: action, address: address, platform_id: platform_id, prod: prod)
24
+ end
25
+
26
+ def initialize(client = nil)
27
+ @client = client || Client.new(base_url: DigiwinDsp.configuration.webhook_base_url)
28
+ end
29
+
30
+ def create(action:, address:, platform_id: nil, prod: DEFAULT_PROD)
31
+ validate_args!(action: action, address: address)
32
+ body = build_body(action: action, address: address, platform_id: platform_id, prod: prod)
33
+ response = @client.post(PATH, body)
34
+ response.dig("std_data", "response") ||
35
+ raise(DigiwinDsp::ServerError, "DSP returned execution.code=0 without std_data.response")
36
+ end
37
+
38
+ private
39
+
40
+ def validate_args!(action:, address:)
41
+ unless ACTIONS.include?(action)
42
+ raise DigiwinDsp::ValidationError,
43
+ "action must be one of #{ACTIONS.inspect} (got #{action.inspect})"
44
+ end
45
+ raise DigiwinDsp::ValidationError, "address is required" if address.nil? || address.to_s.empty?
46
+
47
+ # DSPOOFFICIAL100 mandates HTTPS for the callback ("必須在 30 秒內以
48
+ # HTTPS 回應") — and with no HMAC signing, a plaintext callback URL
49
+ # would be indefensible anyway.
50
+ raise DigiwinDsp::ValidationError, "address must be an https:// URL (got #{address.inspect})" unless address.start_with?("https://")
51
+ return unless address.length > ADDRESS_MAX_LENGTH
52
+
53
+ raise DigiwinDsp::ValidationError,
54
+ "address must be <= #{ADDRESS_MAX_LENGTH} chars (got #{address.length})"
55
+ end
56
+
57
+ def build_body(action:, address:, platform_id:, prod:)
58
+ resolved = resolve_platform_id(platform_id)
59
+ request = { "prod" => prod, "platform_id" => resolved, "action" => action, "address" => address }
60
+ { "digi_body" => { "std_data" => { "parameter" => { "request" => request } } } }
61
+ end
62
+
63
+ def resolve_platform_id(explicit)
64
+ id = explicit || DigiwinDsp.configuration.platform_id
65
+ return id unless id.nil? || id.to_s.empty?
66
+
67
+ raise DigiwinDsp::ConfigurationError,
68
+ "platform_id is required (set via configure or pass explicitly)"
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DigiwinDsp
4
- VERSION = "0.2.4"
4
+ VERSION = "0.3.1"
5
5
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module DigiwinDsp
6
+ module Webhooks
7
+ # Base value-object for an inbound DSP webhook event. Subclasses add
8
+ # action-specific accessors over the `request` payload but share the
9
+ # JSON-parse + envelope-extract logic that lives here.
10
+ class Event
11
+ attr_reader :digi_header, :request, :raw
12
+
13
+ def self.parse(raw_body)
14
+ hash = parse_json(raw_body)
15
+ request = extract_request(hash)
16
+ new(digi_header: hash["digi_header"] || {}, request: request, raw: hash)
17
+ end
18
+
19
+ def self.parse_json(raw_body)
20
+ # max_nesting mirrors the Client's parser DoS guard.
21
+ JSON.parse(raw_body, max_nesting: 50)
22
+ rescue JSON::ParserError => e
23
+ raise ParseError, "invalid JSON: #{e.message}"
24
+ end
25
+
26
+ def self.extract_request(hash)
27
+ raise ParseError, "envelope must be a JSON object" unless hash.is_a?(Hash)
28
+
29
+ hash.dig("digi_body", "std_data", "parameter", "request") ||
30
+ raise(ParseError, "envelope missing digi_body.std_data.parameter.request")
31
+ end
32
+
33
+ private_class_method :parse_json, :extract_request
34
+
35
+ def initialize(digi_header:, request:, raw:)
36
+ @digi_header = digi_header
37
+ @request = request
38
+ @raw = raw
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ module Webhooks
5
+ # Fired by DSP when an ERP-side inventory change happens.
6
+ # See docs/dsp-specs/DSPOOFFICIAL100.yaml under
7
+ # "庫存數量更新 product/inventory_update".
8
+ class InventoryUpdate < Event
9
+ def prod = request["prod"]
10
+ def platform_id = request["platform_id"]
11
+ def sale_page_id = request["sale_page_id"]
12
+ def spec_list = request["spec_list"] || []
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ module Webhooks
5
+ # Fired by DSP when one or more ERP-issued invoices land. Unlike the
6
+ # other two events, the request payload is an Array (DSP batches
7
+ # invoices). See docs/dsp-specs/DSPOOFFICIAL100.yaml under
8
+ # "發票資料更新 invoice/update".
9
+ class InvoiceUpdate < Event
10
+ def self.parse(raw_body)
11
+ event = super
12
+ raise ParseError, "invoice/update payload must be an array of invoice records" unless event.request.is_a?(Array)
13
+
14
+ event
15
+ end
16
+
17
+ def invoices = request
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ module Webhooks
5
+ # Fired by DSP when a shipment changes state (handed off to carrier,
6
+ # delivered, etc.). See docs/dsp-specs/DSPOOFFICIAL100.yaml under
7
+ # "倉儲貨態更新 wms/logistics/package/update".
8
+ class LogisticsUpdate < Event
9
+ def form_no = request["form_no"]
10
+ def func_name = request["func_name"]
11
+ def status_date = request["status_date"]
12
+ def status_time = request["status_time"]
13
+ def tracking_number = request["tracking_number"]
14
+ def distributor_code = request["distributor_code"]
15
+ def message = request["message"]
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ # Inbound webhook payload parsers for events DSP pushes to your Rails app
5
+ # after you register a callback URL via `Resources::WebhookSubscription`.
6
+ #
7
+ # ⚠️ DSP does NOT sign these requests — there is no HMAC header. Defend
8
+ # the callback endpoint with HTTPS-only, an unguessable URL path, and
9
+ # (if possible) an IP allowlist for DSP's egress range.
10
+ module Webhooks
11
+ # Raised when JSON is malformed, the envelope is the wrong shape, or
12
+ # the action is unknown to the gem. Inherits from ValidationError so
13
+ # Rails controllers can rescue the existing DigiwinDsp::Error tree.
14
+ class ParseError < DigiwinDsp::ValidationError; end
15
+
16
+ # action string => constant name (lazy resolution via const_get so
17
+ # this module file doesn't force-load the per-event classes at boot).
18
+ ACTION_REGISTRY = {
19
+ "product/inventory_update" => :InventoryUpdate,
20
+ "wms/logistics/package/update" => :LogisticsUpdate,
21
+ "invoice/update" => :InvoiceUpdate
22
+ }.freeze
23
+
24
+ def self.parse(raw_body, action:)
25
+ sym = ACTION_REGISTRY[action] ||
26
+ raise(ParseError, "unknown action #{action.inspect}; expected one of #{ACTION_REGISTRY.keys.inspect}")
27
+ const_get(sym).parse(raw_body)
28
+ end
29
+ end
30
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: digiwin_dsp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zac
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-22 00:00:00.000000000 Z
11
+ date: 2026-06-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -73,12 +73,18 @@ files:
73
73
  - lib/digiwin_dsp/resources/invoice.rb
74
74
  - lib/digiwin_dsp/resources/order.rb
75
75
  - lib/digiwin_dsp/resources/return.rb
76
+ - lib/digiwin_dsp/resources/webhook_subscription.rb
76
77
  - lib/digiwin_dsp/serializers/base.rb
77
78
  - lib/digiwin_dsp/serializers/cancellation_serializer.rb
78
79
  - lib/digiwin_dsp/serializers/invoice_serializer.rb
79
80
  - lib/digiwin_dsp/serializers/return_serializer.rb
80
81
  - lib/digiwin_dsp/serializers/sales_order_serializer.rb
81
82
  - lib/digiwin_dsp/version.rb
83
+ - lib/digiwin_dsp/webhooks.rb
84
+ - lib/digiwin_dsp/webhooks/event.rb
85
+ - lib/digiwin_dsp/webhooks/inventory_update.rb
86
+ - lib/digiwin_dsp/webhooks/invoice_update.rb
87
+ - lib/digiwin_dsp/webhooks/logistics_update.rb
82
88
  homepage: https://github.com/7a6163/digiwin_dsp
83
89
  licenses:
84
90
  - MIT