nuonuo-sdk 0.2.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Qingdao Ohm Network Technology Co., Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: nuonuo-sdk
3
+ Version: 0.2.0
4
+ Summary: Python SDK for Nuonuo Open Platform invoicing APIs
5
+ Author-email: "Qingdao Ohm Network Technology Co., Ltd." <kevin@odoomommy.com>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/jellyfrank/nuonuo-sdk
8
+ Project-URL: Documentation, https://jss.com.cn/open/
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: requests<3,>=2.31
13
+ Requires-Dist: simplejson>=3.19
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest>=8; extra == "test"
16
+ Requires-Dist: build>=1; extra == "test"
17
+ Dynamic: license-file
18
+
19
+ # 诺诺开放平台 Python SDK
20
+
21
+ 青岛欧姆维护的第三方 `nuonuo-sdk`,导入名 `nuonuo`,独立于 Odoo,要求 Python 3.10+。
22
+ 采用与发票云 SDK 一致的客户端/业务服务风格。诺诺官方也提供 Python 示例;本库在核对该示例协议的基础上提供可安装包、认证、业务方法、错误处理与离线测试,并非官方 SDK。
23
+
24
+ ## 安装与查询
25
+
26
+ ```bash
27
+ pip install nuonuo-sdk==0.2.0
28
+ ```
29
+
30
+ 生产依赖建议固定已验证版本。源码安装也可固定完整提交 SHA。
31
+
32
+ ```python
33
+ import os
34
+ from nuonuo import Nuonuo, PRODUCTION_URL, SANDBOX_URL
35
+
36
+ with Nuonuo(
37
+ app_key=os.environ['NUONUO_APP_KEY'],
38
+ app_secret=os.environ['NUONUO_APP_SECRET'],
39
+ access_token=os.environ['NUONUO_ACCESS_TOKEN'],
40
+ tax_number=os.environ.get('NUONUO_TAX_NUMBER', ''), # 第三方应用必填授权商户税号
41
+ base_url=SANDBOX_URL, # 正式环境使用 PRODUCTION_URL,凭据也必须匹配环境
42
+ ) as client:
43
+ pending = client.invoice.pending(extension_num='0')
44
+ # 使用已经持久保存的原业务单号查询;需应用具有此接口权限。
45
+ result = client.invoice.query(order_nos=['your-existing-order-no'])
46
+ ```
47
+
48
+ 文档标明 `100188` 等部分接口不支持沙箱;不能假定每个方法都能在沙箱联调。
49
+ `100075` 是最近 72 小时内的极速开票待开票列表,不是直接开票接口。
50
+
51
+ ## 认证
52
+
53
+ 自用型应用首次获取:
54
+
55
+ ```python
56
+ with Nuonuo(app_key='your-app-key', app_secret='your-app-secret') as client:
57
+ token_data = client.get_merchant_token()
58
+ # 将完整 token_data 写入应用自己的安全存储,后续实例使用 access_token 初始化。
59
+ ```
60
+
61
+ 获取成功后会更新当前实例 token。自用型应用在到期前显式再次调用 `get_merchant_token()`。
62
+ 官方说明 token 默认有效 24 小时、30 天内调用上限 50 次;可配置永久有效。
63
+ 本库不猜测永久 token 的 expires_in 表示,不自动申请或刷新。调用方按账号和环境共享持久化令牌、协调刷新锁并根据返回期限调度;勿每个请求重新取 token。
64
+
65
+ 服务商/第三方应用:
66
+
67
+ ```python
68
+ url = client.authorization_url('https://your-app.example/callback', state='secure-random-state')
69
+ # 让用户完成授权,服务端核验回调 state 后:
70
+ token_data = client.exchange_code(code, tax_number, 'https://your-app.example/callback')
71
+ # 保存 access_token、refresh_token、userId、expires_in。刷新时 client_id 是 userId:
72
+ new_token_data = client.refresh_isv_token(refresh_token, user_id)
73
+ ```
74
+
75
+ 授权码一次性使用;不要对不确定结果自动重试交换。刷新后安全保存新令牌,保留原 userId(刷新响应可能不返回它)。
76
+ 每个实例对应一个应用、环境及商户,不跨线程共享实例;重建 ISV 实例时同时传入对应税号。
77
+
78
+ ## 业务接口
79
+
80
+ 所有返回值均为完整字典,保留 `code`、`describe`、`result`。业务错误也原样返回,不凭通用成功码自动抛异常。
81
+
82
+ | 方法 | 文档 ID | 用途 |
83
+ | --- | --- | --- |
84
+ | `client.invoice.pending(extension_num=None)` | 100075 | 极速开票待开票列表 |
85
+ | `client.invoice.query(serial_nos=[...])` 或 `query(order_nos=[...])` | 100188 | 开票结果,1–50 个标识 |
86
+ | `client.invoice.pdf_url(data)` | 100185 | 获取 PDF 地址,不下载 |
87
+ | `client.invoice.inspect(data)` | 100136 | 发票查验,可能消耗额度 |
88
+ | `client.invoice.cancel(data)` | 100166 | 对符合条件的发票作废 |
89
+ | `client.invoice.redeliver(data)` | 100249 | 明确调用时向短信/邮箱重新交付 |
90
+ | `client.invoice.issue_red(data)` | 101018 | 微信/支付宝联用蓝票的全额冲红 |
91
+ | `client.nst.issue(order)` | 100607 | 诺税通 SaaS 请求开票 |
92
+ | `client.nst.query(order_nos=[...], include_details=True)` | 专用测试账号实测 | 诺税通 SaaS 开票结果,1–50 个标识 |
93
+ | `client.nst.list_invoices(data)` | 100595 | 诺税通 SaaS 发票列表 |
94
+ | `client.call(method, data)` | 按实际接口 | 其他开放平台 API 通用入口 |
95
+
96
+ `data` 使用官方字段名,区分大小写。除待开票、结果查询外,业务方法为薄封装,字段必填、长度、税目、金额一致性、红票资格等由调用方按当前接口文档校验。金额可传 `Decimal`,不会先转换为 float;响应 JSON 数值也保留 Decimal。官方标为 String 的金额字段仍应传字符串。
97
+
98
+ 诺税通 SaaS 开票要求对应产品资质与接口授权,`nst.issue` 接受 **order 内部字段**,自动包装成 `{"order": ...}`,不可重复嵌套。调用前保存 `orderNo`(每企业唯一)、完整请求及业务状态,按官方文档填写购销方、明细、`invoiceDate`、`invoiceType` 等字段。不提供可直接运行的真实开票样例,以免将演示数据提交为税票。
99
+ `invoice.issue_red` 不是适用所有数电票的通用冲红入口;完整红字确认单流程不在本版业务封装内。
100
+
101
+ ## 下载票文件
102
+
103
+ `from nuonuo.documents import download_document`,调用
104
+ `download_document(url, allowed_hosts={"inv.jss.com.cn"}, kind="pdf")` 返回 `Document(name, mimetype, data)`。
105
+ 调用前校验查询结果中的订单、购销方、票种与金额,再使用接口返回的文件地址。
106
+ 允许域名由管理员确认,不从返回 URL 自动加入;仅 HTTPS、无重定向、单文件最多 10 MB,
107
+ 不携带应用凭据、netrc 凭据或继承代理配置。支持 PDF/OFD/XML;文件头检查不替代税票验真。
108
+
109
+ ## 签名、错误与恢复
110
+
111
+ - 按官方 Python 2.0.0 示例:固定路径 `/open/v1/services`,Base64(HMAC-SHA1),UTF-8 JSON 原文参与签名。公共参数放查询串,业务 JSON 放请求体;nonce 为文档要求的 8 位正整数。
112
+ - 每次请求自动产生 32 位 `senid`,也可通过各方法的 `senid=` 显式传入。它是通信标识,不等同于业务幂等键。
113
+ - 不自动重试业务请求,也不在 token 错误后自动刷新重发;默认连接/读取超时分别为 5/30 秒,拒绝 HTTP 重定向。
114
+ - `TransportError`:网络或非 2xx HTTP;`ProtocolError`:无效 JSON/响应结构;`AuthenticationError`:未设置 token 或获取失败。异常文本不包含凭据、URL 或远端原始报文。
115
+ - `E0000`、`S0000` 等业务码含义取决于接口。开票“提交成功”仅代表受理;根据原单号/流水号查询最终开票状态。
116
+ - 超时、响应丢失或解析失败表示结果未知。先查询原业务单,不生成新订单号自动补开;如果仍不确定,交由业务核实。
117
+ - SDK 不记录请求/返回值。返回字典含税票和个人信息,调用方审计时应脱敏。
118
+
119
+ ## 开发与验证
120
+
121
+ ```bash
122
+ python -m pip install -e '.[test]'
123
+ python -m pytest -q
124
+ python -m build
125
+ ```
126
+
127
+ 离线测试使用合成数据,覆盖官方签名向量、实际发送字节、认证参数、金额精度、错误和防重复提交行为。
128
+ GitHub Actions 配置 Python 3.10–3.14。2026-09-22 使用诺诺确认只产生测试数据的专用账号,
129
+ 已通过列表查询、单张数电普通蓝票提交、原订单结果查询和 PDF/OFD 下载。
130
+ 该测试账号使用正式网关;不能仅凭网关域名判定账号会否产生真实税票。
131
+ 历史 Juhui 沙箱配置的 `070601` 记录保留供排查,当前账号已通过验签及业务调用。
132
+ 详见 [沙箱联调记录](docs/sandbox-validation.md)。
133
+
134
+ 接口取证、文档版本和已知边界见 [docs/api-contract.md](docs/api-contract.md)。
@@ -0,0 +1,116 @@
1
+ # 诺诺开放平台 Python SDK
2
+
3
+ 青岛欧姆维护的第三方 `nuonuo-sdk`,导入名 `nuonuo`,独立于 Odoo,要求 Python 3.10+。
4
+ 采用与发票云 SDK 一致的客户端/业务服务风格。诺诺官方也提供 Python 示例;本库在核对该示例协议的基础上提供可安装包、认证、业务方法、错误处理与离线测试,并非官方 SDK。
5
+
6
+ ## 安装与查询
7
+
8
+ ```bash
9
+ pip install nuonuo-sdk==0.2.0
10
+ ```
11
+
12
+ 生产依赖建议固定已验证版本。源码安装也可固定完整提交 SHA。
13
+
14
+ ```python
15
+ import os
16
+ from nuonuo import Nuonuo, PRODUCTION_URL, SANDBOX_URL
17
+
18
+ with Nuonuo(
19
+ app_key=os.environ['NUONUO_APP_KEY'],
20
+ app_secret=os.environ['NUONUO_APP_SECRET'],
21
+ access_token=os.environ['NUONUO_ACCESS_TOKEN'],
22
+ tax_number=os.environ.get('NUONUO_TAX_NUMBER', ''), # 第三方应用必填授权商户税号
23
+ base_url=SANDBOX_URL, # 正式环境使用 PRODUCTION_URL,凭据也必须匹配环境
24
+ ) as client:
25
+ pending = client.invoice.pending(extension_num='0')
26
+ # 使用已经持久保存的原业务单号查询;需应用具有此接口权限。
27
+ result = client.invoice.query(order_nos=['your-existing-order-no'])
28
+ ```
29
+
30
+ 文档标明 `100188` 等部分接口不支持沙箱;不能假定每个方法都能在沙箱联调。
31
+ `100075` 是最近 72 小时内的极速开票待开票列表,不是直接开票接口。
32
+
33
+ ## 认证
34
+
35
+ 自用型应用首次获取:
36
+
37
+ ```python
38
+ with Nuonuo(app_key='your-app-key', app_secret='your-app-secret') as client:
39
+ token_data = client.get_merchant_token()
40
+ # 将完整 token_data 写入应用自己的安全存储,后续实例使用 access_token 初始化。
41
+ ```
42
+
43
+ 获取成功后会更新当前实例 token。自用型应用在到期前显式再次调用 `get_merchant_token()`。
44
+ 官方说明 token 默认有效 24 小时、30 天内调用上限 50 次;可配置永久有效。
45
+ 本库不猜测永久 token 的 expires_in 表示,不自动申请或刷新。调用方按账号和环境共享持久化令牌、协调刷新锁并根据返回期限调度;勿每个请求重新取 token。
46
+
47
+ 服务商/第三方应用:
48
+
49
+ ```python
50
+ url = client.authorization_url('https://your-app.example/callback', state='secure-random-state')
51
+ # 让用户完成授权,服务端核验回调 state 后:
52
+ token_data = client.exchange_code(code, tax_number, 'https://your-app.example/callback')
53
+ # 保存 access_token、refresh_token、userId、expires_in。刷新时 client_id 是 userId:
54
+ new_token_data = client.refresh_isv_token(refresh_token, user_id)
55
+ ```
56
+
57
+ 授权码一次性使用;不要对不确定结果自动重试交换。刷新后安全保存新令牌,保留原 userId(刷新响应可能不返回它)。
58
+ 每个实例对应一个应用、环境及商户,不跨线程共享实例;重建 ISV 实例时同时传入对应税号。
59
+
60
+ ## 业务接口
61
+
62
+ 所有返回值均为完整字典,保留 `code`、`describe`、`result`。业务错误也原样返回,不凭通用成功码自动抛异常。
63
+
64
+ | 方法 | 文档 ID | 用途 |
65
+ | --- | --- | --- |
66
+ | `client.invoice.pending(extension_num=None)` | 100075 | 极速开票待开票列表 |
67
+ | `client.invoice.query(serial_nos=[...])` 或 `query(order_nos=[...])` | 100188 | 开票结果,1–50 个标识 |
68
+ | `client.invoice.pdf_url(data)` | 100185 | 获取 PDF 地址,不下载 |
69
+ | `client.invoice.inspect(data)` | 100136 | 发票查验,可能消耗额度 |
70
+ | `client.invoice.cancel(data)` | 100166 | 对符合条件的发票作废 |
71
+ | `client.invoice.redeliver(data)` | 100249 | 明确调用时向短信/邮箱重新交付 |
72
+ | `client.invoice.issue_red(data)` | 101018 | 微信/支付宝联用蓝票的全额冲红 |
73
+ | `client.nst.issue(order)` | 100607 | 诺税通 SaaS 请求开票 |
74
+ | `client.nst.query(order_nos=[...], include_details=True)` | 专用测试账号实测 | 诺税通 SaaS 开票结果,1–50 个标识 |
75
+ | `client.nst.list_invoices(data)` | 100595 | 诺税通 SaaS 发票列表 |
76
+ | `client.call(method, data)` | 按实际接口 | 其他开放平台 API 通用入口 |
77
+
78
+ `data` 使用官方字段名,区分大小写。除待开票、结果查询外,业务方法为薄封装,字段必填、长度、税目、金额一致性、红票资格等由调用方按当前接口文档校验。金额可传 `Decimal`,不会先转换为 float;响应 JSON 数值也保留 Decimal。官方标为 String 的金额字段仍应传字符串。
79
+
80
+ 诺税通 SaaS 开票要求对应产品资质与接口授权,`nst.issue` 接受 **order 内部字段**,自动包装成 `{"order": ...}`,不可重复嵌套。调用前保存 `orderNo`(每企业唯一)、完整请求及业务状态,按官方文档填写购销方、明细、`invoiceDate`、`invoiceType` 等字段。不提供可直接运行的真实开票样例,以免将演示数据提交为税票。
81
+ `invoice.issue_red` 不是适用所有数电票的通用冲红入口;完整红字确认单流程不在本版业务封装内。
82
+
83
+ ## 下载票文件
84
+
85
+ `from nuonuo.documents import download_document`,调用
86
+ `download_document(url, allowed_hosts={"inv.jss.com.cn"}, kind="pdf")` 返回 `Document(name, mimetype, data)`。
87
+ 调用前校验查询结果中的订单、购销方、票种与金额,再使用接口返回的文件地址。
88
+ 允许域名由管理员确认,不从返回 URL 自动加入;仅 HTTPS、无重定向、单文件最多 10 MB,
89
+ 不携带应用凭据、netrc 凭据或继承代理配置。支持 PDF/OFD/XML;文件头检查不替代税票验真。
90
+
91
+ ## 签名、错误与恢复
92
+
93
+ - 按官方 Python 2.0.0 示例:固定路径 `/open/v1/services`,Base64(HMAC-SHA1),UTF-8 JSON 原文参与签名。公共参数放查询串,业务 JSON 放请求体;nonce 为文档要求的 8 位正整数。
94
+ - 每次请求自动产生 32 位 `senid`,也可通过各方法的 `senid=` 显式传入。它是通信标识,不等同于业务幂等键。
95
+ - 不自动重试业务请求,也不在 token 错误后自动刷新重发;默认连接/读取超时分别为 5/30 秒,拒绝 HTTP 重定向。
96
+ - `TransportError`:网络或非 2xx HTTP;`ProtocolError`:无效 JSON/响应结构;`AuthenticationError`:未设置 token 或获取失败。异常文本不包含凭据、URL 或远端原始报文。
97
+ - `E0000`、`S0000` 等业务码含义取决于接口。开票“提交成功”仅代表受理;根据原单号/流水号查询最终开票状态。
98
+ - 超时、响应丢失或解析失败表示结果未知。先查询原业务单,不生成新订单号自动补开;如果仍不确定,交由业务核实。
99
+ - SDK 不记录请求/返回值。返回字典含税票和个人信息,调用方审计时应脱敏。
100
+
101
+ ## 开发与验证
102
+
103
+ ```bash
104
+ python -m pip install -e '.[test]'
105
+ python -m pytest -q
106
+ python -m build
107
+ ```
108
+
109
+ 离线测试使用合成数据,覆盖官方签名向量、实际发送字节、认证参数、金额精度、错误和防重复提交行为。
110
+ GitHub Actions 配置 Python 3.10–3.14。2026-09-22 使用诺诺确认只产生测试数据的专用账号,
111
+ 已通过列表查询、单张数电普通蓝票提交、原订单结果查询和 PDF/OFD 下载。
112
+ 该测试账号使用正式网关;不能仅凭网关域名判定账号会否产生真实税票。
113
+ 历史 Juhui 沙箱配置的 `070601` 记录保留供排查,当前账号已通过验签及业务调用。
114
+ 详见 [沙箱联调记录](docs/sandbox-validation.md)。
115
+
116
+ 接口取证、文档版本和已知边界见 [docs/api-contract.md](docs/api-contract.md)。
@@ -0,0 +1,6 @@
1
+ from .client import Nuonuo, PRODUCTION_URL, SANDBOX_URL
2
+ from .exceptions import AuthenticationError, NuonuoError, ProtocolError, TransportError
3
+
4
+ __version__ = '0.2.0'
5
+ __all__ = ['Nuonuo', 'PRODUCTION_URL', 'SANDBOX_URL', 'NuonuoError',
6
+ 'AuthenticationError', 'ProtocolError', 'TransportError']
@@ -0,0 +1,169 @@
1
+ """Synchronous, instance-scoped Nuonuo client. No implicit retries or token requests."""
2
+ import secrets
3
+ import time
4
+ import uuid
5
+ from collections.abc import Mapping
6
+ from urllib.parse import urlencode, urlsplit
7
+
8
+ import requests
9
+ import simplejson as json
10
+
11
+ from .exceptions import AuthenticationError, ProtocolError, TransportError
12
+ from .invoice import Invoice, Nst
13
+ from .signing import sign
14
+
15
+ PRODUCTION_URL = 'https://sdk.nuonuo.com/open/v1/services'
16
+ SANDBOX_URL = 'https://sandbox.nuonuocs.cn/open/v1/services'
17
+ TOKEN_URL = 'https://open.nuonuo.com/accessToken'
18
+ AUTHORIZE_URL = 'https://open.nuonuo.com/authorize'
19
+
20
+
21
+ def _https_url(value, path=None):
22
+ parsed = urlsplit(value)
23
+ if (parsed.scheme != 'https' or not parsed.hostname or parsed.username
24
+ or parsed.password or parsed.query or parsed.fragment
25
+ or (path and parsed.path != path)):
26
+ raise ValueError('Expected an HTTPS endpoint with no credentials, query or fragment')
27
+ return value
28
+
29
+
30
+ def _text(value, name):
31
+ if not isinstance(value, str) or not value.strip() or '\r' in value or '\n' in value:
32
+ raise ValueError(f'{name} must be a non-empty, single-line string')
33
+ return value
34
+
35
+
36
+ class Nuonuo:
37
+ """One instance per application, environment and authorized merchant.
38
+
39
+ Tokens are acquired explicitly because the platform limits token requests.
40
+ Persist token responses in the caller's secret store; reuse across workers.
41
+ """
42
+
43
+ def __init__(self, app_key, app_secret, *, access_token=None, tax_number='',
44
+ base_url=PRODUCTION_URL, token_url=TOKEN_URL, timeout=(5, 30)):
45
+ self.app_key = _text(app_key, 'app_key')
46
+ self._app_secret = _text(app_secret, 'app_secret')
47
+ self.base_url = _https_url(base_url, '/open/v1/services')
48
+ self.token_url = _https_url(token_url)
49
+ self.tax_number = _text(tax_number, 'tax_number') if tax_number else ''
50
+ values = timeout if isinstance(timeout, tuple) else (timeout,)
51
+ if len(values) not in (1, 2) or any(not isinstance(v, (int, float)) or not 0 < v < float('inf') for v in values):
52
+ raise ValueError('timeout must contain positive finite seconds')
53
+ self.timeout = timeout
54
+ self._session = requests.Session()
55
+ self._access_token = None
56
+ if access_token is not None:
57
+ self.set_access_token(access_token)
58
+ self.invoice = Invoice(self)
59
+ self.nst = Nst(self)
60
+
61
+ def _post(self, url, **kwargs):
62
+ try:
63
+ response = self._session.post(
64
+ url, timeout=self.timeout, allow_redirects=False, **kwargs,
65
+ )
66
+ except requests.RequestException:
67
+ raise TransportError('Network request failed; remote outcome may be unknown') from None
68
+ try:
69
+ if not 200 <= response.status_code < 300:
70
+ raise TransportError(f'Unexpected HTTP status {response.status_code}; no retry performed')
71
+ try:
72
+ data = json.loads(response.content, use_decimal=True)
73
+ except (ValueError, UnicodeError):
74
+ raise ProtocolError('Response is not valid JSON') from None
75
+ if not isinstance(data, dict):
76
+ raise ProtocolError('Expected a JSON object')
77
+ return data
78
+ finally:
79
+ response.close()
80
+
81
+ def _token_request(self, fields):
82
+ data = self._post(self.token_url, data=fields)
83
+ token = data.get('access_token')
84
+ if not isinstance(token, str) or not token.strip() or '\r' in token or '\n' in token:
85
+ raise AuthenticationError('Token endpoint returned no valid access token')
86
+ self.set_access_token(token)
87
+ return data
88
+
89
+ def set_access_token(self, token):
90
+ """Install a persisted or freshly obtained token without an HTTP request."""
91
+ self._access_token = _text(token, 'access_token')
92
+
93
+ def get_merchant_token(self):
94
+ """Self-use application token. Caller manages expiry and shared persistence."""
95
+ return self._token_request({
96
+ 'client_id': self.app_key, 'client_secret': self._app_secret,
97
+ 'grant_type': 'client_credentials',
98
+ })
99
+
100
+ def authorization_url(self, redirect_uri, state):
101
+ """Caller must verify returned state before exchanging the authorization code."""
102
+ return AUTHORIZE_URL + '?' + urlencode({
103
+ 'appKey': self.app_key, 'response_type': 'code',
104
+ 'redirect_uri': _text(redirect_uri, 'redirect_uri'),
105
+ 'state': _text(state, 'state'),
106
+ })
107
+
108
+ def exchange_code(self, code, tax_number, redirect_uri):
109
+ """Exchange a one-use ISV authorization code and bind this merchant."""
110
+ tax_number = _text(tax_number, 'tax_number')
111
+ data = self._token_request({
112
+ 'client_id': self.app_key, 'client_secret': self._app_secret,
113
+ 'grant_type': 'authorization_code', 'code': _text(code, 'code'),
114
+ 'taxNum': tax_number, 'redirect_uri': _text(redirect_uri, 'redirect_uri'),
115
+ })
116
+ self.tax_number = tax_number
117
+ return data
118
+
119
+ def refresh_isv_token(self, refresh_token, user_id):
120
+ """client_id is the authorized user's userId, NOT appKey, for refresh."""
121
+ return self._token_request({
122
+ 'client_id': _text(user_id, 'user_id'), 'client_secret': self._app_secret,
123
+ 'grant_type': 'refresh_token',
124
+ 'refresh_token': _text(refresh_token, 'refresh_token'),
125
+ })
126
+
127
+ def call(self, method, data, *, senid=None):
128
+ """Return the full business envelope, including error codes, unchanged.
129
+
130
+ senid identifies transport requests, not a substitute for order identity.
131
+ There is deliberately no auto-refresh, retry, or success-code guessing.
132
+ """
133
+ _text(method, 'method')
134
+ if not isinstance(data, Mapping):
135
+ raise TypeError('data must be a mapping of official API fields')
136
+ if not self._access_token:
137
+ raise AuthenticationError('Set or explicitly acquire an access token first')
138
+ if senid is None:
139
+ senid = uuid.uuid4().hex
140
+ if not isinstance(senid, str) or len(senid) != 32 or not senid.isascii() or not senid.isalnum():
141
+ raise ValueError('senid must contain 32 ASCII letters or digits')
142
+ content = json.dumps(dict(data), ensure_ascii=False, separators=(',', ':'),
143
+ use_decimal=True, allow_nan=False)
144
+ # simplejson permits non-finite Decimal values; strict parse catches them too.
145
+ json.loads(content, allow_nan=False)
146
+ timestamp = str(int(time.time()))
147
+ nonce = str(10_000_000 + secrets.randbelow(90_000_000))
148
+ result = self._post(
149
+ self.base_url,
150
+ params={'senid': senid, 'nonce': nonce, 'timestamp': timestamp, 'appkey': self.app_key},
151
+ headers={
152
+ 'Content-Type': 'application/json; charset=UTF-8',
153
+ 'X-Nuonuo-Sign': sign(self._app_secret, self.app_key, senid, nonce, content, timestamp),
154
+ 'accessToken': self._access_token, 'userTax': self.tax_number, 'method': method,
155
+ },
156
+ data=content.encode('utf-8'),
157
+ )
158
+ if not isinstance(result.get('code'), (str, int)):
159
+ raise ProtocolError('Response has no business code')
160
+ return result
161
+
162
+ def close(self):
163
+ self._session.close()
164
+
165
+ def __enter__(self):
166
+ return self
167
+
168
+ def __exit__(self, *args):
169
+ self.close()
@@ -0,0 +1,52 @@
1
+ """Bounded downloads; allow-list is administrator configuration, never vendor input."""
2
+ from dataclasses import dataclass
3
+ from urllib.parse import urlsplit
4
+
5
+ import requests
6
+
7
+ from .exceptions import ProtocolError, TransportError
8
+
9
+ MAX_BYTES = 10 * 1024 * 1024
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Document:
14
+ name: str
15
+ mimetype: str
16
+ data: bytes
17
+
18
+
19
+ def download_document(url, *, allowed_hosts, kind='pdf'):
20
+ """Download one PDF/OFD/XML without credentials or redirects (10 MB limit)."""
21
+ kinds = {'pdf': 'application/pdf', 'ofd': 'application/ofd', 'xml': 'application/xml'}
22
+ if kind not in kinds:
23
+ raise ValueError('Unsupported document kind')
24
+ parsed = urlsplit(url)
25
+ hosts = {host.strip().lower() for host in allowed_hosts if host.strip()}
26
+ if (parsed.scheme != 'https' or not parsed.hostname or parsed.hostname.lower() not in hosts
27
+ or parsed.username or parsed.password or parsed.port not in (None, 443) or parsed.fragment):
28
+ raise ValueError('Document URL is not allowed')
29
+ try:
30
+ with requests.Session() as session:
31
+ session.trust_env = False # Never inherit netrc credentials or proxy settings.
32
+ with session.get(url, timeout=(5, 30), stream=True, allow_redirects=False) as response:
33
+ if response.status_code != 200:
34
+ raise TransportError('Document download failed')
35
+ chunks, size = [], 0
36
+ for chunk in response.iter_content(64 * 1024):
37
+ size += len(chunk)
38
+ if size > MAX_BYTES:
39
+ raise ProtocolError('Document exceeds 10 MB')
40
+ chunks.append(chunk)
41
+ except requests.RequestException:
42
+ raise TransportError('Document download failed') from None
43
+ content = b''.join(chunks)
44
+ if not content:
45
+ raise ProtocolError('Empty document')
46
+ if kind == 'pdf' and not content.startswith(b'%PDF-'):
47
+ raise ProtocolError('Response is not PDF')
48
+ if kind == 'ofd' and not content.startswith(b'PK'):
49
+ raise ProtocolError('Response is not an OFD container')
50
+ if kind == 'xml' and not content.lstrip(b'\xef\xbb\xbf \r\n\t').startswith(b'<'):
51
+ raise ProtocolError('Response is not XML')
52
+ return Document('invoice.' + kind, kinds[kind], content)
@@ -0,0 +1,17 @@
1
+ """Errors deliberately omit URLs, credentials and remote response bodies."""
2
+
3
+
4
+ class NuonuoError(Exception):
5
+ """Base SDK error."""
6
+
7
+
8
+ class TransportError(NuonuoError):
9
+ """Outcome may be unknown; never blindly repeat an invoice submission."""
10
+
11
+
12
+ class ProtocolError(NuonuoError):
13
+ """The remote response did not follow the expected JSON contract."""
14
+
15
+
16
+ class AuthenticationError(NuonuoError):
17
+ """Token endpoint rejected the request or returned no token."""
@@ -0,0 +1,79 @@
1
+ """Thin business wrappers preserve official field spelling and business responses."""
2
+ from collections.abc import Mapping
3
+
4
+
5
+ class Invoice:
6
+ def __init__(self, client):
7
+ self._client = client
8
+
9
+ def pending(self, extension_num=None, *, senid=None):
10
+ """100075: speed-billing requests within the platform's 72-hour window."""
11
+ data = {} if extension_num is None else {'extensionNum': str(extension_num)}
12
+ return self._client.call('nuonuo.speedBilling.querySpeedBilling', data, senid=senid)
13
+
14
+ def query(self, *, serial_nos=None, order_nos=None, include_details=False, senid=None):
15
+ """100188: query up to 50 existing order numbers OR invoice serials."""
16
+ if bool(serial_nos) == bool(order_nos):
17
+ raise ValueError('Provide either serial_nos or order_nos')
18
+ values = serial_nos if serial_nos else order_nos
19
+ if (not isinstance(values, (list, tuple)) or not 1 <= len(values) <= 50
20
+ or any(not isinstance(v, str) or not v.strip() for v in values)):
21
+ raise ValueError('Provide 1 to 50 non-empty string identifiers')
22
+ data = {'serialNos' if serial_nos else 'orderNos': list(values),
23
+ 'isOfferInvoiceDetail': '1' if include_details else '0'}
24
+ return self._client.call('nuonuo.ElectronInvoice.queryInvoiceResult', data, senid=senid)
25
+
26
+ def pdf_url(self, data, *, senid=None):
27
+ """100185: return the PDF URL envelope; does not download the file."""
28
+ return self._client.call('nuonuo.ElectronInvoice.getPDF', data, senid=senid)
29
+
30
+ def inspect(self, data, *, senid=None):
31
+ """100136: invoice authenticity inspection; may consume paid quota."""
32
+ return self._client.call('nuonuo.electronInvoice.invoiceInspection', data, senid=senid)
33
+
34
+ def cancel(self, data, *, senid=None):
35
+ """100166: cancel an eligible issued invoice."""
36
+ return self._client.call('nuonuo.electronInvoice.invoiceCancellation', data, senid=senid)
37
+
38
+ def redeliver(self, data, *, senid=None):
39
+ """100249: explicitly send an existing invoice via provider email/SMS."""
40
+ return self._client.call('nuonuo.ElectronInvoice.deliveryInvoice', data, senid=senid)
41
+
42
+ def issue_red(self, data, *, senid=None):
43
+ """101018: full red reversal, only for supported WeChat/Alipay linked blue invoices."""
44
+ return self._client.call('nuonuo.ElectronInvoice.unifiedfastInvoiceRed', data, senid=senid)
45
+
46
+
47
+ class Nst:
48
+ """Separate Nuoshuitong SaaS product; requires corresponding entitlement."""
49
+
50
+ def __init__(self, client):
51
+ self._client = client
52
+
53
+ def issue(self, order, *, senid=None):
54
+ """100607: submit official order fields; persist order identity BEFORE calling."""
55
+ if not isinstance(order, Mapping):
56
+ raise TypeError('order must be a mapping of official order fields')
57
+ order_no = order.get('orderNo')
58
+ if not isinstance(order_no, str) or not order_no.strip() or len(order_no) > 64:
59
+ raise ValueError('Persist a non-empty orderNo of at most 64 characters before submission')
60
+ return self._client.call('nuonuo.OpeMplatform.requestBillingNew', {'order': order}, senid=senid)
61
+
62
+ def list_invoices(self, data, *, senid=None):
63
+ """100595: paginated invoice list; caller supplies official filter fields."""
64
+ return self._client.call('nuonuo.OpeMplatform.queryInvoiceList', data, senid=senid)
65
+
66
+ def query(self, *, serial_nos=None, order_nos=None, include_details=False, senid=None):
67
+ """NST result lookup verified against the authorized test account.
68
+
69
+ The payload matches the invoice result query, but the product namespace differs.
70
+ """
71
+ if bool(serial_nos) == bool(order_nos):
72
+ raise ValueError('Provide either serial_nos or order_nos')
73
+ values = serial_nos if serial_nos else order_nos
74
+ if (not isinstance(values, (list, tuple)) or not 1 <= len(values) <= 50
75
+ or any(not isinstance(v, str) or not v.strip() for v in values)):
76
+ raise ValueError('Provide 1 to 50 non-empty string identifiers')
77
+ data = {'serialNos' if serial_nos else 'orderNos': list(values),
78
+ 'isOfferInvoiceDetail': '1' if include_details else '0'}
79
+ return self._client.call('nuonuo.OpeMplatform.queryInvoiceResult', data, senid=senid)
@@ -0,0 +1,14 @@
1
+ """Nuonuo /open/v1/services signature, matching the official Python sample."""
2
+ import base64
3
+ import hashlib
4
+ import hmac
5
+
6
+
7
+ def sign(secret, app_key, senid, nonce, content, timestamp):
8
+ """Sign the exact Unicode JSON text subsequently sent as UTF-8 bytes."""
9
+ message = (
10
+ f"a=services&l=v1&p=open&k={app_key}&i={senid}"
11
+ f"&n={nonce}&t={timestamp}&f={content}"
12
+ )
13
+ digest = hmac.new(secret.encode('utf-8'), message.encode('utf-8'), hashlib.sha1).digest()
14
+ return base64.b64encode(digest).decode('ascii')
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: nuonuo-sdk
3
+ Version: 0.2.0
4
+ Summary: Python SDK for Nuonuo Open Platform invoicing APIs
5
+ Author-email: "Qingdao Ohm Network Technology Co., Ltd." <kevin@odoomommy.com>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/jellyfrank/nuonuo-sdk
8
+ Project-URL: Documentation, https://jss.com.cn/open/
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: requests<3,>=2.31
13
+ Requires-Dist: simplejson>=3.19
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest>=8; extra == "test"
16
+ Requires-Dist: build>=1; extra == "test"
17
+ Dynamic: license-file
18
+
19
+ # 诺诺开放平台 Python SDK
20
+
21
+ 青岛欧姆维护的第三方 `nuonuo-sdk`,导入名 `nuonuo`,独立于 Odoo,要求 Python 3.10+。
22
+ 采用与发票云 SDK 一致的客户端/业务服务风格。诺诺官方也提供 Python 示例;本库在核对该示例协议的基础上提供可安装包、认证、业务方法、错误处理与离线测试,并非官方 SDK。
23
+
24
+ ## 安装与查询
25
+
26
+ ```bash
27
+ pip install nuonuo-sdk==0.2.0
28
+ ```
29
+
30
+ 生产依赖建议固定已验证版本。源码安装也可固定完整提交 SHA。
31
+
32
+ ```python
33
+ import os
34
+ from nuonuo import Nuonuo, PRODUCTION_URL, SANDBOX_URL
35
+
36
+ with Nuonuo(
37
+ app_key=os.environ['NUONUO_APP_KEY'],
38
+ app_secret=os.environ['NUONUO_APP_SECRET'],
39
+ access_token=os.environ['NUONUO_ACCESS_TOKEN'],
40
+ tax_number=os.environ.get('NUONUO_TAX_NUMBER', ''), # 第三方应用必填授权商户税号
41
+ base_url=SANDBOX_URL, # 正式环境使用 PRODUCTION_URL,凭据也必须匹配环境
42
+ ) as client:
43
+ pending = client.invoice.pending(extension_num='0')
44
+ # 使用已经持久保存的原业务单号查询;需应用具有此接口权限。
45
+ result = client.invoice.query(order_nos=['your-existing-order-no'])
46
+ ```
47
+
48
+ 文档标明 `100188` 等部分接口不支持沙箱;不能假定每个方法都能在沙箱联调。
49
+ `100075` 是最近 72 小时内的极速开票待开票列表,不是直接开票接口。
50
+
51
+ ## 认证
52
+
53
+ 自用型应用首次获取:
54
+
55
+ ```python
56
+ with Nuonuo(app_key='your-app-key', app_secret='your-app-secret') as client:
57
+ token_data = client.get_merchant_token()
58
+ # 将完整 token_data 写入应用自己的安全存储,后续实例使用 access_token 初始化。
59
+ ```
60
+
61
+ 获取成功后会更新当前实例 token。自用型应用在到期前显式再次调用 `get_merchant_token()`。
62
+ 官方说明 token 默认有效 24 小时、30 天内调用上限 50 次;可配置永久有效。
63
+ 本库不猜测永久 token 的 expires_in 表示,不自动申请或刷新。调用方按账号和环境共享持久化令牌、协调刷新锁并根据返回期限调度;勿每个请求重新取 token。
64
+
65
+ 服务商/第三方应用:
66
+
67
+ ```python
68
+ url = client.authorization_url('https://your-app.example/callback', state='secure-random-state')
69
+ # 让用户完成授权,服务端核验回调 state 后:
70
+ token_data = client.exchange_code(code, tax_number, 'https://your-app.example/callback')
71
+ # 保存 access_token、refresh_token、userId、expires_in。刷新时 client_id 是 userId:
72
+ new_token_data = client.refresh_isv_token(refresh_token, user_id)
73
+ ```
74
+
75
+ 授权码一次性使用;不要对不确定结果自动重试交换。刷新后安全保存新令牌,保留原 userId(刷新响应可能不返回它)。
76
+ 每个实例对应一个应用、环境及商户,不跨线程共享实例;重建 ISV 实例时同时传入对应税号。
77
+
78
+ ## 业务接口
79
+
80
+ 所有返回值均为完整字典,保留 `code`、`describe`、`result`。业务错误也原样返回,不凭通用成功码自动抛异常。
81
+
82
+ | 方法 | 文档 ID | 用途 |
83
+ | --- | --- | --- |
84
+ | `client.invoice.pending(extension_num=None)` | 100075 | 极速开票待开票列表 |
85
+ | `client.invoice.query(serial_nos=[...])` 或 `query(order_nos=[...])` | 100188 | 开票结果,1–50 个标识 |
86
+ | `client.invoice.pdf_url(data)` | 100185 | 获取 PDF 地址,不下载 |
87
+ | `client.invoice.inspect(data)` | 100136 | 发票查验,可能消耗额度 |
88
+ | `client.invoice.cancel(data)` | 100166 | 对符合条件的发票作废 |
89
+ | `client.invoice.redeliver(data)` | 100249 | 明确调用时向短信/邮箱重新交付 |
90
+ | `client.invoice.issue_red(data)` | 101018 | 微信/支付宝联用蓝票的全额冲红 |
91
+ | `client.nst.issue(order)` | 100607 | 诺税通 SaaS 请求开票 |
92
+ | `client.nst.query(order_nos=[...], include_details=True)` | 专用测试账号实测 | 诺税通 SaaS 开票结果,1–50 个标识 |
93
+ | `client.nst.list_invoices(data)` | 100595 | 诺税通 SaaS 发票列表 |
94
+ | `client.call(method, data)` | 按实际接口 | 其他开放平台 API 通用入口 |
95
+
96
+ `data` 使用官方字段名,区分大小写。除待开票、结果查询外,业务方法为薄封装,字段必填、长度、税目、金额一致性、红票资格等由调用方按当前接口文档校验。金额可传 `Decimal`,不会先转换为 float;响应 JSON 数值也保留 Decimal。官方标为 String 的金额字段仍应传字符串。
97
+
98
+ 诺税通 SaaS 开票要求对应产品资质与接口授权,`nst.issue` 接受 **order 内部字段**,自动包装成 `{"order": ...}`,不可重复嵌套。调用前保存 `orderNo`(每企业唯一)、完整请求及业务状态,按官方文档填写购销方、明细、`invoiceDate`、`invoiceType` 等字段。不提供可直接运行的真实开票样例,以免将演示数据提交为税票。
99
+ `invoice.issue_red` 不是适用所有数电票的通用冲红入口;完整红字确认单流程不在本版业务封装内。
100
+
101
+ ## 下载票文件
102
+
103
+ `from nuonuo.documents import download_document`,调用
104
+ `download_document(url, allowed_hosts={"inv.jss.com.cn"}, kind="pdf")` 返回 `Document(name, mimetype, data)`。
105
+ 调用前校验查询结果中的订单、购销方、票种与金额,再使用接口返回的文件地址。
106
+ 允许域名由管理员确认,不从返回 URL 自动加入;仅 HTTPS、无重定向、单文件最多 10 MB,
107
+ 不携带应用凭据、netrc 凭据或继承代理配置。支持 PDF/OFD/XML;文件头检查不替代税票验真。
108
+
109
+ ## 签名、错误与恢复
110
+
111
+ - 按官方 Python 2.0.0 示例:固定路径 `/open/v1/services`,Base64(HMAC-SHA1),UTF-8 JSON 原文参与签名。公共参数放查询串,业务 JSON 放请求体;nonce 为文档要求的 8 位正整数。
112
+ - 每次请求自动产生 32 位 `senid`,也可通过各方法的 `senid=` 显式传入。它是通信标识,不等同于业务幂等键。
113
+ - 不自动重试业务请求,也不在 token 错误后自动刷新重发;默认连接/读取超时分别为 5/30 秒,拒绝 HTTP 重定向。
114
+ - `TransportError`:网络或非 2xx HTTP;`ProtocolError`:无效 JSON/响应结构;`AuthenticationError`:未设置 token 或获取失败。异常文本不包含凭据、URL 或远端原始报文。
115
+ - `E0000`、`S0000` 等业务码含义取决于接口。开票“提交成功”仅代表受理;根据原单号/流水号查询最终开票状态。
116
+ - 超时、响应丢失或解析失败表示结果未知。先查询原业务单,不生成新订单号自动补开;如果仍不确定,交由业务核实。
117
+ - SDK 不记录请求/返回值。返回字典含税票和个人信息,调用方审计时应脱敏。
118
+
119
+ ## 开发与验证
120
+
121
+ ```bash
122
+ python -m pip install -e '.[test]'
123
+ python -m pytest -q
124
+ python -m build
125
+ ```
126
+
127
+ 离线测试使用合成数据,覆盖官方签名向量、实际发送字节、认证参数、金额精度、错误和防重复提交行为。
128
+ GitHub Actions 配置 Python 3.10–3.14。2026-09-22 使用诺诺确认只产生测试数据的专用账号,
129
+ 已通过列表查询、单张数电普通蓝票提交、原订单结果查询和 PDF/OFD 下载。
130
+ 该测试账号使用正式网关;不能仅凭网关域名判定账号会否产生真实税票。
131
+ 历史 Juhui 沙箱配置的 `070601` 记录保留供排查,当前账号已通过验签及业务调用。
132
+ 详见 [沙箱联调记录](docs/sandbox-validation.md)。
133
+
134
+ 接口取证、文档版本和已知边界见 [docs/api-contract.md](docs/api-contract.md)。
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ nuonuo/__init__.py
5
+ nuonuo/client.py
6
+ nuonuo/documents.py
7
+ nuonuo/exceptions.py
8
+ nuonuo/invoice.py
9
+ nuonuo/signing.py
10
+ nuonuo_sdk.egg-info/PKG-INFO
11
+ nuonuo_sdk.egg-info/SOURCES.txt
12
+ nuonuo_sdk.egg-info/dependency_links.txt
13
+ nuonuo_sdk.egg-info/requires.txt
14
+ nuonuo_sdk.egg-info/top_level.txt
15
+ tests/test_sdk.py
@@ -0,0 +1,6 @@
1
+ requests<3,>=2.31
2
+ simplejson>=3.19
3
+
4
+ [test]
5
+ pytest>=8
6
+ build>=1
@@ -0,0 +1 @@
1
+ nuonuo
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nuonuo-sdk"
7
+ version = "0.2.0"
8
+ description = "Python SDK for Nuonuo Open Platform invoicing APIs"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "Qingdao Ohm Network Technology Co., Ltd.", email = "kevin@odoomommy.com"}]
14
+ dependencies = ["requests>=2.31,<3", "simplejson>=3.19"]
15
+
16
+ [project.urls]
17
+ Repository = "https://github.com/jellyfrank/nuonuo-sdk"
18
+ Documentation = "https://jss.com.cn/open/"
19
+
20
+ [project.optional-dependencies]
21
+ test = ["pytest>=8", "build>=1"]
22
+
23
+ [tool.setuptools.packages.find]
24
+ include = ["nuonuo*"]
25
+
26
+ [tool.pytest.ini_options]
27
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,224 @@
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ from decimal import Decimal
5
+ from unittest.mock import Mock
6
+ from urllib.parse import parse_qs, urlsplit
7
+
8
+ import pytest
9
+ import requests
10
+ import simplejson as json
11
+
12
+ from nuonuo import Nuonuo, SANDBOX_URL, AuthenticationError, ProtocolError, TransportError
13
+ from nuonuo.signing import sign
14
+
15
+
16
+ @pytest.fixture
17
+ def client():
18
+ with Nuonuo('app', 'secret', access_token='token', tax_number='tax', base_url=SANDBOX_URL) as c:
19
+ c._session.post = Mock()
20
+ c._session.post.return_value = response({'code': 'E0000', 'result': {}})
21
+ yield c
22
+
23
+
24
+ def response(data, status=200):
25
+ r = requests.Response()
26
+ r.status_code = status
27
+ r._content = json.dumps(data).encode()
28
+ r._content_consumed = True
29
+ return r
30
+
31
+
32
+ def test_signature_official_vector():
33
+ # Frozen against official NNOpenSDK.py get_sign; synthetic credentials only.
34
+ assert sign('secret', 'app', 'a' * 32, '12345678', '{"name":"中文"}', '1700000000') == '4BzcKzrcoqxPis0W/W3X2ae0uIo='
35
+
36
+
37
+ def test_wire_bytes_and_precision(client, monkeypatch):
38
+ monkeypatch.setattr('nuonuo.client.time.time', lambda: 1700000000)
39
+ monkeypatch.setattr('nuonuo.client.secrets.randbelow', lambda n: 2345678)
40
+ client.call('nuonuo.example', {'name': '中文', 'amount': Decimal('123456789.123456789')}, senid='a' * 32)
41
+ args, kw = client._session.post.call_args
42
+ assert args == (SANDBOX_URL,)
43
+ assert kw['params'] == {'appkey': 'app', 'senid': 'a' * 32, 'nonce': '12345678', 'timestamp': '1700000000'}
44
+ assert kw['data'] == '{"name":"中文","amount":123456789.123456789}'.encode()
45
+ source = b'a=services&l=v1&p=open&k=app&i=' + b'a' * 32 + b'&n=12345678&t=1700000000&f=' + kw['data']
46
+ expected = base64.b64encode(hmac.new(b'secret', source, hashlib.sha1).digest()).decode()
47
+ assert kw['headers']['X-Nuonuo-Sign'] == expected
48
+ assert kw['headers']['accessToken'] == 'token'
49
+ assert kw['headers']['userTax'] == 'tax'
50
+ assert kw['allow_redirects'] is False
51
+ assert kw['timeout'] == (5, 30)
52
+
53
+
54
+ @pytest.mark.parametrize('code', ['E0000', 'S0000', '200', 'S0101', 'E9500', 'invalid_token'])
55
+ def test_business_envelope_not_interpreted_or_retried(client, code):
56
+ expected = {'code': code, 'describe': '说明', 'result': [1, 2]}
57
+ client._session.post.return_value = response(expected)
58
+ assert client.invoice.pending() == expected
59
+ assert client._session.post.call_count == 1
60
+
61
+
62
+ @pytest.mark.parametrize('status', [301, 302, 400, 401, 429, 500])
63
+ def test_http_failure_no_retry(client, status):
64
+ client._session.post.return_value = response({'secret': 'sensitive'}, status)
65
+ with pytest.raises(TransportError) as exc:
66
+ client.invoice.pending()
67
+ assert 'sensitive' not in str(exc.value)
68
+ assert client._session.post.call_count == 1
69
+
70
+
71
+ def test_timeout_no_retry_or_leaked_credentials(client):
72
+ client._session.post.side_effect = requests.Timeout('https://secret.example/?accessToken=private')
73
+ with pytest.raises(TransportError) as exc:
74
+ client.invoice.pending()
75
+ assert 'private' not in str(exc.value)
76
+ assert exc.value.__suppress_context__
77
+ assert client._session.post.call_count == 1
78
+
79
+
80
+ @pytest.mark.parametrize('content', [b'<html>error</html>', b'[]', b'null', b'{}', b'{"code":null}', b'\xff'])
81
+ def test_malformed_response(client, content):
82
+ client._session.post.return_value._content = content
83
+ with pytest.raises(ProtocolError):
84
+ client.invoice.pending()
85
+
86
+
87
+ def test_token_contracts(client):
88
+ client._session.post.return_value = response({'access_token': 'new', 'expires_in': '86400', 'refresh_token': 'r', 'userId': 'u'})
89
+ assert client.get_merchant_token()['access_token'] == 'new'
90
+ assert client._session.post.call_args.kwargs['data'] == {
91
+ 'client_id': 'app', 'client_secret': 'secret', 'grant_type': 'client_credentials'}
92
+ client.exchange_code('code', 'merchant', 'https://example.com/callback')
93
+ fields = client._session.post.call_args.kwargs['data']
94
+ assert fields['taxNum'] == 'merchant'
95
+ assert fields['grant_type'] == 'authorization_code'
96
+ assert client.tax_number == 'merchant'
97
+ client.refresh_isv_token('r', 'u')
98
+ assert client._session.post.call_args.kwargs['data'] == {
99
+ 'client_id': 'u', 'client_secret': 'secret', 'grant_type': 'refresh_token', 'refresh_token': 'r'}
100
+
101
+
102
+ def test_token_rejection_does_not_replace_token(client):
103
+ client._session.post.return_value = response({'error': 'invalid_client', 'error_description': 'sensitive'})
104
+ with pytest.raises(AuthenticationError) as exc:
105
+ client.get_merchant_token()
106
+ assert 'sensitive' not in str(exc.value)
107
+ assert client._access_token == 'token'
108
+
109
+
110
+ def test_authorization_url(client):
111
+ query = parse_qs(urlsplit(client.authorization_url('https://example.com/cb?a=1&b=2', 'a+b')).query)
112
+ assert query == {'appKey': ['app'], 'response_type': ['code'], 'redirect_uri': ['https://example.com/cb?a=1&b=2'], 'state': ['a+b']}
113
+
114
+
115
+ def test_no_implicit_auth_and_instance_isolation(client):
116
+ with Nuonuo('other', 'other-secret') as other:
117
+ other._session.post = Mock()
118
+ with pytest.raises(AuthenticationError):
119
+ other.invoice.pending()
120
+ other._session.post.assert_not_called()
121
+ other.set_access_token('other-token')
122
+ assert client._access_token == 'token'
123
+
124
+
125
+ @pytest.mark.parametrize('kwargs', [{}, {'order_nos': 'abc'}, {'order_nos': ['']}, {'order_nos': ['a'] * 51}, {'order_nos': ['a'], 'serial_nos': ['b']}])
126
+ def test_query_validation(client, kwargs):
127
+ with pytest.raises(ValueError):
128
+ client.invoice.query(**kwargs)
129
+ client._session.post.assert_not_called()
130
+
131
+
132
+ def test_query_shape(client):
133
+ client.invoice.query(order_nos=['order'], include_details=True)
134
+ assert json.loads(client._session.post.call_args.kwargs['data']) == {'orderNos': ['order'], 'isOfferInvoiceDetail': '1'}
135
+
136
+
137
+ @pytest.mark.parametrize('group,name,method', [
138
+ ('invoice', 'pdf_url', 'nuonuo.ElectronInvoice.getPDF'),
139
+ ('invoice', 'inspect', 'nuonuo.electronInvoice.invoiceInspection'),
140
+ ('invoice', 'cancel', 'nuonuo.electronInvoice.invoiceCancellation'),
141
+ ('invoice', 'redeliver', 'nuonuo.ElectronInvoice.deliveryInvoice'),
142
+ ('invoice', 'issue_red', 'nuonuo.ElectronInvoice.unifiedfastInvoiceRed'),
143
+ ('nst', 'issue', 'nuonuo.OpeMplatform.requestBillingNew'),
144
+ ('nst', 'list_invoices', 'nuonuo.OpeMplatform.queryInvoiceList'),
145
+ ])
146
+ def test_documented_method_mapping(client, group, name, method):
147
+ getattr(getattr(client, group), name)({'orderNo': 'existing'}, senid='b' * 32)
148
+ kw = client._session.post.call_args.kwargs
149
+ assert kw['headers']['method'] == method
150
+ assert json.loads(kw['data']) == ({'order': {'orderNo': 'existing'}} if name == 'issue' else {'orderNo': 'existing'})
151
+
152
+
153
+ @pytest.mark.parametrize('value', [float('nan'), float('inf'), Decimal('NaN'), Decimal('Infinity')])
154
+ def test_reject_nonfinite_money(client, value):
155
+ with pytest.raises(ValueError):
156
+ client.call('nuonuo.test', {'amount': value})
157
+ client._session.post.assert_not_called()
158
+
159
+
160
+ @pytest.mark.parametrize('url', ['http://example.com/open/v1/services', 'https://user:pass@example.com/open/v1/services', 'https://example.com/wrong', SANDBOX_URL + '?x=1'])
161
+ def test_endpoint_validation(url):
162
+ with pytest.raises(ValueError):
163
+ Nuonuo('a', 'b', base_url=url)
164
+
165
+
166
+ @pytest.mark.parametrize('senid', ['', 'a', '中' * 32, '-' * 32])
167
+ def test_senid_validation(client, senid):
168
+ with pytest.raises(ValueError):
169
+ client.invoice.pending(senid=senid)
170
+ client._session.post.assert_not_called()
171
+
172
+
173
+ @pytest.mark.parametrize('order', [{}, {'orderNo': ''}, {'orderNo': 'x' * 65}, None])
174
+ def test_issue_requires_persistable_identity(client, order):
175
+ with pytest.raises((ValueError, TypeError)):
176
+ client.nst.issue(order)
177
+ client._session.post.assert_not_called()
178
+
179
+
180
+ def test_response_decimal_preserved(client):
181
+ client._session.post.return_value._content = b'{"code":"E0000","result":{"amount":123.4567890123456789}}'
182
+ assert client.invoice.pending()['result']['amount'] == Decimal('123.4567890123456789')
183
+
184
+
185
+ def test_nst_query_namespace(client):
186
+ client.nst.query(order_nos=['persisted'], include_details=True)
187
+ kw = client._session.post.call_args.kwargs
188
+ assert kw['headers']['method'] == 'nuonuo.OpeMplatform.queryInvoiceResult'
189
+ assert json.loads(kw['data']) == {'orderNos': ['persisted'], 'isOfferInvoiceDetail': '1'}
190
+
191
+
192
+ @pytest.mark.parametrize('kwargs', [{}, {'order_nos': 'a'}, {'order_nos': ['a'] * 51}, {'serial_nos': ['a'], 'order_nos': ['a']}])
193
+ def test_nst_query_validation(client, kwargs):
194
+ with pytest.raises(ValueError):
195
+ client.nst.query(**kwargs)
196
+ client._session.post.assert_not_called()
197
+
198
+
199
+ @pytest.mark.parametrize('url', ['http://files.example/a', 'https://evil.example/a', 'https://u:p@files.example/a', 'https://files.example:8443/a'])
200
+ def test_document_host_guard(url):
201
+ from nuonuo.documents import download_document
202
+ with pytest.raises(ValueError):
203
+ download_document(url, allowed_hosts=['files.example'])
204
+
205
+
206
+ def test_download_bounded_and_no_redirect(monkeypatch):
207
+ from nuonuo.documents import download_document
208
+ from unittest.mock import MagicMock
209
+ session = MagicMock()
210
+ monkeypatch.setattr('nuonuo.documents.requests.Session', lambda: session)
211
+ r = session.__enter__.return_value.get.return_value.__enter__.return_value
212
+ r.status_code = 200
213
+ r.iter_content.return_value = [b'%PDF-synthetic']
214
+ doc = download_document('https://files.example/a', allowed_hosts=['files.example'])
215
+ assert doc.data == b'%PDF-synthetic'
216
+ assert session.__enter__.return_value.trust_env is False
217
+ assert session.__enter__.return_value.get.call_args.kwargs['allow_redirects'] is False
218
+ r.status_code = 302
219
+ with pytest.raises(TransportError):
220
+ download_document('https://files.example/a', allowed_hosts=['files.example'])
221
+ r.status_code = 200
222
+ r.iter_content.return_value = [b'x' * (10 * 1024 * 1024 + 1)]
223
+ with pytest.raises(ProtocolError):
224
+ download_document('https://files.example/a', allowed_hosts=['files.example'])