Files
server-core/wechatpayv3/fapiao.py
T
Pine f1147dced3 feat(pay): 微信支付V3子应用与算力充值
- app/pay 独立支付子应用(config/wxpay/models/repository/service/routers),便于后期拆分微服务
- 充值订单表 0026_compute_recharge;套餐支持折扣(discount)与上架开关(enabled),DB system_configs 优先、env 兜底
- 统一小程序支付:桌面端出小程序码→扫码进小程序确认页按 openid 发起 JSAPI→回调幂等到账(引擎 adjust_user_quota+镜像回写)
- 内嵌 wechatpayv3(含 async_),响应验签失败降级告警;新增 aiofiles 依赖

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 22:29:44 +08:00

280 lines
13 KiB
Python

# -*- coding: utf-8 -*-
import os.path
from .type import RequestType
from .utils import sm3
# https://pay.weixin.qq.com/wiki/doc/apiv3/Offline/open/chapter4_8_1.shtml
def fapiao_card_template(self, card_template_information, card_appid=None):
"""创建电子发票卡券模板
:param card_template_information: 卡券模板信息。示例值:{'logo_url':'http://mmbiz.qpic.cn/mmbiz/iaL1LJM1mF9aRKPZJkmG8xX'}
:param card_appid: 插卡公众号AppID,若是服务商模式,则可以是服务商申请的appid,也可以是子商户申请的appid;若是直连模式,则是直连商户申请的appid。示例值:wxb1170446a4c0a5a2
"""
if not card_appid:
card_appid = self._appid
params = {}
params.update({'card_appid': card_appid})
if card_template_information:
params.update({'card_template_information': card_template_information})
else:
raise Exception('card_template_information is not assigned.')
path = '/v3/new-tax-control-fapiao/card-template'
return self._core.request(path, method=RequestType.POST, data=params)
def fapiao_set_merchant_config(self, callback_url=None):
"""配置开发选项
:param callback_url: 商户回调地址。收取微信的授权通知、开票通知、插卡通知等相关通知。示例值:'https://pay.weixin.qq.com/callback'
"""
if not callback_url:
callback_url = self._notify_url
params = {}
params.update({'callback_url': callback_url})
path = '/v3/new-tax-control-fapiao/merchant/development-config'
return self._core.request(path, method=RequestType.PATCH, data=params)
def fapiao_merchant_config(self):
"""查询商户配置的开发选项
"""
path = '/v3/new-tax-control-fapiao/merchant/development-config'
return self._core.request(path)
def fapiao_title_url(self, fapiao_apply_id, source, total_amount, openid, appid=None,
seller_name=None, show_phone_cell=False, must_input_phone=False,
show_email_cell=False, must_input_email=False):
"""获取抬头填写链接
:param fapiao_apply_id: 发票申请单号,示例值:'4200000444201910177461284488'
:param source: 开票来源,WEB:微信H5开票,MINIPROGRAM:微信小程序开票,示例值:'WEB'
:param total_amount: 总金额,单位:分,示例值:100
:param openid: 需要填写发票抬头的用户在商户AppID下的OpenID,示例值:'plN5twRbHym_j-QcqCzstl0HmwEs'
:param appid: 若开票来源是WEB,则为商户的公众号AppID;若开票来源是MINIPROGRAM,则为商户的小程序AppID,示例值:'wxb1170446a4c0a5a2'
:param seller_name: 销售方名称,若不传则默认取商户名称,示例值:'深圳市南山区测试商户'
:param show_phone_cell: 是否需要展示手机号填写栏
:param must_input_phone: 是否必须填写手机号,仅当需要展示手机号填写栏时生效
:param show_email_cell: 是否需要展示邮箱地址填写栏
:param must_input_email: 是否必须填写邮箱地址,仅当需要展示邮箱地址填写栏时生效
"""
path = '/v3/new-tax-control-fapiao/user-title/title-url?'
if fapiao_apply_id:
path += 'fapiao_apply_id=%s' % fapiao_apply_id
else:
raise Exception('fapiao_apply_id is not assigned.')
if source:
path += '&source=%s' % source
else:
raise Exception('source is not assigned.')
if total_amount:
path += '&total_amount=%s' % total_amount
else:
raise Exception('total_amount is not assigned.')
if appid:
path += '&appid=%s' % appid
else:
path += '&appid=%s' % self._appid
if openid:
path += '&openid=%s' % openid
else:
raise Exception('openid is not assigned.')
if seller_name:
path += '&seller_name=%s' % seller_name
if show_phone_cell:
path += '&show_phone_cell=true'
if must_input_phone:
path += '&must_input_phone=true'
if show_email_cell:
path += '&show_email_cell=true'
if must_input_email:
path += '&must_input_email=true'
return self._core.request(path)
def fapiao_title(self, fapiao_apply_id, scene='WITH_WECHATPAY'):
"""获取用户填写的抬头
:param fapiao_apply_id: 发票申请单号,示例值:'4200000444201910177461284488'
:param scene: 场景值,目前只支持WITH_WECHATPAY。示例值:'WITH_WECHATPAY'
"""
path = '/v3/new-tax-control-fapiao/user-title?'
if fapiao_apply_id:
path += 'fapiao_apply_id=%s' % fapiao_apply_id
else:
raise Exception('fapiao_apply_id is not assigned.')
path += '&scene=%s' % scene
return self._core.request(path)
def fapiao_tax_codes(self, offset=0, limit=20):
"""获取商品和服务税收分类对照表
:param offset: 查询的起始位置,示例值:0
:param limit: 查询的最大数量,最大值20
"""
path = '/v3/new-tax-control-fapiao/merchant/tax-codes?offset=%s&limit=%s' % (offset, limit)
return self._core.request(path)
def fapiao_merchant_base_info(self):
"""获取商户开票基础信息
"""
path = '/v3/new-tax-control-fapiao/merchant/base-information'
return self._core.request(path)
def fapiao_applications(self, fapiao_apply_id, buyer_information, fapiao_information, scene='WITH_WECHATPAY'):
"""开具电子发票
:param fapiao_apply_id: 发票申请单号,示例值:'4200000444201910177461284488'
:param buyer_information: 购买方信息,示例值:{'type':'ORGANIZATION','name':'深圳市南山区测试企业'}
:param fapiao_information: 需要开具的发票信息,示例值:[{'fapiao_id':'20200701123456','total_amount':382895,'need_list':False,'items':[{'tax_code':'3010101020203000000','quantity':100000000,'total_amount':'429900','discount':False}]}]
:param scene: 场景值,目前只支持WITH_WECHATPAY。示例值:'WITH_WECHATPAY'
"""
params = {}
if fapiao_apply_id:
params.update({'fapiao_apply_id': fapiao_apply_id})
else:
raise Exception('fapiao_aply_id is not assigned.')
cipher_data = False
if buyer_information:
if buyer_information.get('phone'):
buyer_information.update({'phone': self._core.encrypt(buyer_information.get('phone'))})
cipher_data = True
if buyer_information.get('email'):
buyer_information.update({'email': self._core.encrypt(buyer_information.get('email'))})
cipher_data = True
params.update({'buyer_information': buyer_information})
else:
raise Exception('buyer_information is not assigned.')
if fapiao_information:
params.update({'fapiao_information': fapiao_information})
else:
raise Exception('fapiao_information is not assigned.')
params.update({'scene': scene})
path = '/v3/new-tax-control-fapiao/fapiao-applications'
return self._core.request(path, method=RequestType.POST, data=params, cipher_data=cipher_data)
def fapiao_query(self, fapiao_apply_id, fapiao_id=None):
"""查询电子发票
:param fapiao_apply_id: 发票申请单号,示例值:'4200000444201910177461284488'
:param fapiao_id: 商户发票单号,示例值:'20200701123456'
"""
path = '/v3/new-tax-control-fapiao/fapiao-applications/%s' % fapiao_apply_id
if fapiao_id:
path += '?fapiao_id=%s' % fapiao_id
return self._core.request(path)
def fapiao_reverse(self, fapiao_apply_id, reverse_reason, fapiao_information):
"""冲红电子发票
:param fapiao_apply_id: 发票申请单号,示例值:'4200000444201910177461284488'
:param reverse_reason: 冲红原因,示例值:'退款'
:param fapiao_information: 需要冲红的发票信息,示例值:{'fapiao_id':'20200701123456','fapiao_code':'044001911211','fapiao_number':'12897794'}
"""
if fapiao_apply_id:
path = '/v3/new-tax-control-fapiao/fapiao-applications/%s/reverse' % fapiao_apply_id
else:
raise Exception('fapiao_apply_id is not assigned.')
params = {}
if reverse_reason:
params.update({'reverse_reason': reverse_reason})
else:
raise Exception('reverse_reason is not assigned.')
if fapiao_information:
params.update({'fapiao_information': fapiao_information})
else:
raise Exception('fapiao_information is not assigned.')
return self._core.request(path, method=RequestType.POST, data=params)
def fapiao_upload_file(self, filepath):
"""上传电子发票文件
:filepath: 电子发票文件路径,只支持pdf和odf两种格式,示例值:'./fapiao/0001.pdf'
"""
if not (filepath and os.path.exists(filepath) and os.path.isfile(filepath)):
raise Exception('filepath is not assigned or not exists')
with open(filepath, mode='rb') as f:
content = f.read()
filename = os.path.basename(filepath)
filetype = os.path.splitext(filename)[-1][1:].upper()
mimes = {
'PDF': 'application/pdf',
'ODF': 'application/odf'
}
if filetype not in mimes:
raise Exception(f'wechatpayv3 does not support this file type: {filetype}')
params = {}
params.update({'meta': '{"file_type":"%s","digest_alogrithm":"SM3","digest":"%s"}' % (filetype, sm3(content))})
files = [('file', (filename, content, mimes[filetype]))]
path = '/v3/new-tax-control-fapiao/fapiao-applications/upload-fapiao-file'
return self._core.request(path, method=RequestType.POST, data=params, sign_data=params.get('meta'), files=files)
def fapiao_insert_cards(self, fapiao_apply_id, buyer_information, fapiao_card_information, scene='WITH_WECHATPAY'):
"""将电子发票插入微信用户卡包
:param fapiao_apply_id: 发票申请单号,示例值:'4200000444201910177461284488'
:param buyer_information: 购买方信息,即发票抬头。示例值:{'type':'ORGANIZATION','name':'深圳市南山区测试企业'}
:param fapiao_card_information: 电子发票卡券信息列表,最多五条。示例值:[{'fapiao_media_id':'ASNFZ4mrze/+3LqYdlQyEA==','fapiao_number':'123456','fapiao_code':'044001911211','fapiao_time':'2020-07-01T12:00:00+08:00','check_code':'69001808340631374774'......}]
:param scene: 场景值,目前只支持WITH_WECHATPAY。示例值:'WITH_WECHATPAY'
"""
if fapiao_apply_id:
path = '/v3/new-tax-control-fapiao/fapiao-applications/%s/insert-cards' % fapiao_apply_id
else:
raise Exception('fapiao_apply_id is not assigned.')
params = {}
if buyer_information:
params.update({'buyer_information': buyer_information})
else:
raise Exception('buyer_information is not assigned.')
if fapiao_card_information:
params.update({'fapiao_card_information': fapiao_card_information})
else:
raise Exception('fapiao_card_information is not assigned.')
params.update({'scene': scene})
return self._core.request(path, method=RequestType.POST, data=params)
def fapiao_check_submch(self, sub_mchid):
"""检查子商户开票功能状态
:param sub_mch: 子商户号,微信支付分配的子商户号。示例值:'1900000001'
"""
if sub_mchid:
path = '/v3/new-tax-control-fapiao/merchant/%s/check' % sub_mchid
else:
raise Exception('sub_mchid is not assigned.')
return self._core.request(path)
def fapiao_query_files(self, fapiao_apply_id, sub_mchid=None, fapiao_id=None):
"""获取发票下载信息
:param fapiao_apply_id: 发票申请单号,开票时指定的发票申请单号。
:param sub_mchid: 子商户号,微信支付分配的子商户号。示例值:'1900000001'
:param fapiao_id: 商户发票单号,开票时指定的商户发票单号,唯一标识一张电子发票。
"""
if fapiao_apply_id:
path = '/v3/new-tax-control-fapiao/fapiao-applications/%s/fapiao-files' % fapiao_apply_id
else:
raise Exception('fapiao_apply_id is not assigned.')
params = {}
if sub_mchid:
params.update({'sub_mchid': sub_mchid})
if fapiao_id:
params.update({'fapiao_id': fapiao_id})
return self._core.request(path, method=RequestType.POST, data=params)
def fapiao_download_file(self, url, openid, invoice_code, invoice_no, fapiao_id, sub_mchid=None):
"""下载发票文件
:param url: 获取发票下载信息接口返回的download_url,保留其中的token字段不要删除。
"""
if not (url and openid and invoice_code and invoice_no and fapiao_id):
raise Exception('url, openid, invoice_code, invocide_no or fapiao_id is not assigned.')
else:
path = '%s&mchid=%s&openid=%s&invoice_code=%s&invoice_no=%s&fapiao_id=%s' % (url, self._mchid, openid, invoice_code, invoice_no, fapiao_id)
if self._partner_mode:
if sub_mchid:
path = '%s&sub_mchid=%s' % (path, sub_mchid)
else:
raise Exception('sub_mchid is not assigned.')
return self._core.request(path)