digiwin_dsp 0.2.4 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: feebd7c9c5d89e9d6306b00ecccb402151c9e299201ecf3b98cfbf7c9a1590a1
4
- data.tar.gz: 7c9fbb5aa913649c417a4f23caf6bf9f3b10bc92b6c2efb34e9cc1cdce8808ad
3
+ metadata.gz: 6a72275c796b57bd858c4d7c5a67b7074e2738985ae92a9011836c0db9383321
4
+ data.tar.gz: 78db9041211bbc64cf7b9cb9cbfccb5f1dbef00efbfc4d5aa7b7a9e44d458f63
5
5
  SHA512:
6
- metadata.gz: 292e2d148f9af342959a826cbed33999cbd81b169719fa79882b309bfdfb6b1800e88ed18dfcd3d0210784d85f7df9dea1e02e63b0a0f9e67e276e235c7c403d
7
- data.tar.gz: 437cd4412d96aa9914b99cea75ca5ce06274870aada7a2462b53d64e18eb5ebb03b9dbee07bd78cadab08880652aabe49e7ce88ff987c043be7530ff031c3712
6
+ metadata.gz: 82cc7dbb4ca9194b851ee1bdcd853fc6c084d13ae41d6d1c38ebefec8fba80be722bf40e77e332db7ef1e821aec8d8522358740bcd4531e21744fcb66844f9fa
7
+ data.tar.gz: ba67eb5115c8aa4d978b71f52c1ac063eb2643bdaccdfa30cb9ed3f3dfd1022b3378497c04cfc468d317a211453d983751ce2cfba635eeceb6d9e8c98044655c
data/CHANGELOG.md CHANGED
@@ -6,6 +6,32 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-05-28
10
+
11
+ 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.
12
+
13
+ ### Added
14
+
15
+ - **`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.
16
+ - **`DigiwinDsp::Webhooks` module** with three inbound event parsers:
17
+ - `Webhooks::InventoryUpdate` — exposes `prod`, `platform_id`, `sale_page_id`, `spec_list`
18
+ - `Webhooks::LogisticsUpdate` — exposes `form_no`, `func_name`, `status_date`, `status_time`, `tracking_number`, `distributor_code`, `message`
19
+ - `Webhooks::InvoiceUpdate` — exposes `invoices` (Array; DSP batches multiple invoices per push)
20
+ - `Webhooks.parse(raw_body, action:)` dispatcher routes by action string
21
+ - `Webhooks::ParseError < ValidationError` for malformed JSON / envelope / unknown action
22
+ - **`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`.
23
+ - **Client `base_url:` kwarg override** so the new `WebhookSubscription` can target the webhook base path without leaking the routing into `Configuration#base_url`.
24
+ - **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.
25
+ - `docs/dsp-specs/DSPOOFFICIAL100.yaml` — the OpenAPI spec used to drive the implementation.
26
+
27
+ ### Security
28
+
29
+ - ⚠️ **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`.
30
+
31
+ ### Changed
32
+
33
+ - Removed dead `Client#envelope_error_attrs` helper (replaced by `classify_envelope_failure` which serves both envelope shapes).
34
+
9
35
  ## [0.2.4] - 2026-05-22
10
36
 
11
37
  ### Added
@@ -179,7 +205,8 @@ Initial release. Covers the four Self-hosted Website Module (自有官網模組)
179
205
  - The gem is **synchronous on purpose**. Callers wrap requests in their own background job runner (e.g. ActiveJob) when needed.
180
206
  - Idempotency: clients can send `X-Idempotency-Key` via the `idempotency_key:` kwarg. DSP also dedupes server-side by `form_no + platform_id`.
181
207
 
182
- [Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.4...HEAD
208
+ [Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.3.0...HEAD
209
+ [0.3.0]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.4...v0.3.0
183
210
  [0.2.4]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.3...v0.2.4
184
211
  [0.2.3]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.2...v0.2.3
185
212
  [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 (the gem's `address` validation requires this implicitly via `allowed_hosts` if you register through it)
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:
@@ -37,9 +37,10 @@ module DigiwinDsp
37
37
  [/系統異常:/, ServerError] # DSP internal error
38
38
  ].freeze
39
39
 
40
- def initialize(configuration: DigiwinDsp.configuration, authenticator: nil)
41
- @configuration = configuration
42
- @authenticator = authenticator || Authenticator.new(configuration)
40
+ def initialize(configuration: DigiwinDsp.configuration, authenticator: nil, base_url: nil)
41
+ @configuration = configuration
42
+ @authenticator = authenticator || Authenticator.new(configuration)
43
+ @base_url_override = base_url
43
44
  end
44
45
 
45
46
  def post(path, body, idempotency_key: nil, headers: {})
@@ -78,7 +79,7 @@ module DigiwinDsp
78
79
  end
79
80
 
80
81
  def connection_base_url
81
- base = @configuration.base_url
82
+ base = @base_url_override || @configuration.base_url
82
83
  base.end_with?("/") ? base : "#{base}/"
83
84
  end
84
85
 
@@ -108,11 +109,31 @@ module DigiwinDsp
108
109
  end
109
110
 
110
111
  def inspect_envelope(body)
111
- return body unless body.is_a?(Hash) && body["Status"].to_s.casecmp("failure").zero?
112
+ return body unless body.is_a?(Hash)
112
113
 
113
- message = body["Message"].to_s
114
+ failure = detect_envelope_failure(body)
115
+ return body unless failure
116
+
117
+ raise classify_envelope_failure(failure[:message], code: failure[:code], body: body)
118
+ end
119
+
120
+ # Detects either envelope shape and returns {message, code} if it's a
121
+ # failure response, or nil if it's a success / non-envelope body.
122
+ # - DSPOOFFICIAL100: { srvver, std_data: { execution: { code, description }, response } }
123
+ # - DSPOOFFICIAL001-005: { Status, Message, response_detail }
124
+ def detect_envelope_failure(body)
125
+ if (exec = body.dig("std_data", "execution"))
126
+ return nil if exec["code"].to_s == "0"
127
+
128
+ { message: exec["description"].to_s, code: exec["code"] }
129
+ elsif body["Status"].to_s.casecmp("failure").zero?
130
+ { message: body["Message"].to_s, code: body["Status"] }
131
+ end
132
+ end
133
+
134
+ def classify_envelope_failure(message, code:, body:)
114
135
  klass = ENVELOPE_FAILURE_MAP.find { |regex, _| regex.match?(message) }&.last || Error
115
- raise klass.new(message, **envelope_error_attrs(body))
136
+ klass.new(message, code: code, dsp_message: message, request_id: body["request_id"], http_status: 200)
116
137
  end
117
138
 
118
139
  def http_message(status, body)
@@ -130,15 +151,6 @@ module DigiwinDsp
130
151
  }
131
152
  end
132
153
 
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
154
  # Block CRLF/LF/CR in header names and values. RFC 7230 §3.2.4 forbids
143
155
  # them and Faraday + Net::HTTP catch many forms, but not all — close
144
156
  # 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,67 @@
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
+ return unless address.length > ADDRESS_MAX_LENGTH
47
+
48
+ raise DigiwinDsp::ValidationError,
49
+ "address must be <= #{ADDRESS_MAX_LENGTH} chars (got #{address.length})"
50
+ end
51
+
52
+ def build_body(action:, address:, platform_id:, prod:)
53
+ resolved = resolve_platform_id(platform_id)
54
+ request = { "prod" => prod, "platform_id" => resolved, "action" => action, "address" => address }
55
+ { "digi_body" => { "std_data" => { "parameter" => { "request" => request } } } }
56
+ end
57
+
58
+ def resolve_platform_id(explicit)
59
+ id = explicit || DigiwinDsp.configuration.platform_id
60
+ return id unless id.nil? || id.to_s.empty?
61
+
62
+ raise DigiwinDsp::ConfigurationError,
63
+ "platform_id is required (set via configure or pass explicitly)"
64
+ end
65
+ end
66
+ end
67
+ 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.0"
5
5
  end
@@ -0,0 +1,40 @@
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
+ def initialize(digi_header:, request:, raw:)
34
+ @digi_header = digi_header
35
+ @request = request
36
+ @raw = raw
37
+ end
38
+ end
39
+ end
40
+ 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.0
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-05-28 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