Files
oci-portal/internal/oci/billing.go
T

321 lines
10 KiB
Go

package oci
import (
"context"
"fmt"
"io"
"math"
"strings"
"time"
"github.com/oracle/oci-go-sdk/v65/common"
"github.com/oracle/oci-go-sdk/v65/ospgateway"
)
// 账单能力封装 OSP Gateway(发票列表/明细/PDF/付款与订阅付款方式)。
// 该服务只在租户主区域提供,所有请求都要携带 ospHomeRegion 并发往主区域。
// Invoice 是一张发票的摘要(InvoiceSummary 的面板投影)。
type Invoice struct {
ID string `json:"id"`
InternalID string `json:"internalId"`
Number string `json:"number"`
Type string `json:"type"`
Status string `json:"status"`
IsPaid bool `json:"isPaid"`
IsPayable bool `json:"isPayable"`
IsPaymentFailed bool `json:"isPaymentFailed"`
Amount float64 `json:"amount"`
AmountDue float64 `json:"amountDue"`
Currency string `json:"currency"`
TimeInvoice *time.Time `json:"timeInvoice"`
TimeDue *time.Time `json:"timeDue"`
}
// InvoiceLine 是发票内一行费用明细。
type InvoiceLine struct {
Product string `json:"product"`
OrderNo string `json:"orderNo"`
Quantity float64 `json:"quantity"`
UnitPrice float64 `json:"unitPrice"`
Total float64 `json:"total"`
Currency string `json:"currency"`
TimeStart *time.Time `json:"timeStart"`
TimeEnd *time.Time `json:"timeEnd"`
}
// PaymentMethod 是订阅上登记的一种付款方式,按 method 分信用卡/PayPal 两形态。
type PaymentMethod struct {
Method string `json:"method"` // CREDIT_CARD / PAYPAL
CardType string `json:"cardType,omitempty"`
LastDigits string `json:"lastDigits,omitempty"`
NameOnCard string `json:"nameOnCard,omitempty"`
TimeExpiration *time.Time `json:"timeExpiration,omitempty"`
Email string `json:"email,omitempty"`
PayerName string `json:"payerName,omitempty"`
BillingAgreement string `json:"billingAgreement,omitempty"`
}
// ospHomeRegion 解析租户主区域公共名(经测活拿三字码再查区域表);
// 主区域是租户常量,按 tenancy 进程内缓存,免去每次账单调用先付一次远程测活。
func (c *RealClient) ospHomeRegion(ctx context.Context, cred Credentials) (string, error) {
if v, ok := c.homeRegions.Load(cred.TenancyOCID); ok {
return v.(string), nil
}
info, err := c.ValidateKey(ctx, cred)
if err != nil {
return "", fmt.Errorf("resolve osp home region: %w", err)
}
r, ok := RegionByKey(info.HomeRegionKey)
if !ok {
return "", fmt.Errorf("resolve osp home region: unknown region key %q", info.HomeRegionKey)
}
if cred.TenancyOCID != "" {
c.homeRegions.Store(cred.TenancyOCID, r.Name)
}
return r.Name, nil
}
// ospInvoiceClient 构造发票服务客户端并指向主区域;region 同时用于请求参数。
func (c *RealClient) ospInvoiceClient(ctx context.Context, cred Credentials) (ospgateway.InvoiceServiceClient, string, error) {
region, err := c.ospHomeRegion(ctx, cred)
if err != nil {
return ospgateway.InvoiceServiceClient{}, "", err
}
ic, err := ospgateway.NewInvoiceServiceClientWithConfigurationProvider(provider(cred))
if err != nil {
return ospgateway.InvoiceServiceClient{}, "", fmt.Errorf("new invoice client: %w", err)
}
applyProxy(&ic.BaseClient, cred)
ic.SetRegion(region)
return ic, region, nil
}
// ListInvoices 实现 Client:分页列出租户发票;year>0 时只取该自然年
// (按 timeInvoice 窗口过滤),为 0 拉全量。
func (c *RealClient) ListInvoices(ctx context.Context, cred Credentials, year int) ([]Invoice, error) {
ic, region, err := c.ospInvoiceClient(ctx, cred)
if err != nil {
return nil, err
}
req := ospgateway.ListInvoicesRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
Limit: common.Int(ospPageLimit),
}
if year > 0 {
req.TimeInvoiceStart = &common.SDKTime{Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)}
req.TimeInvoiceEnd = &common.SDKTime{Time: time.Date(year+1, 1, 1, 0, 0, 0, 0, time.UTC)}
}
var out []Invoice
for {
resp, err := ic.ListInvoices(ctx, req)
if err != nil {
return nil, fmt.Errorf("list invoices: %w", err)
}
for _, item := range resp.Items {
out = append(out, toInvoice(item))
}
if resp.OpcNextPage == nil {
return orEmpty(out), nil
}
req.Page = resp.OpcNextPage
}
}
// ospPageLimit 是 OSP 网关列表接口的每页条数;不传时服务端默认页极小,
// 几百行发票明细要串行翻几十页(实测单请求被拖到 40s+)。
const ospPageLimit = 100
// ListInvoiceLines 实现 Client:分页列出一张发票的全部费用行。
func (c *RealClient) ListInvoiceLines(ctx context.Context, cred Credentials, internalInvoiceID string) ([]InvoiceLine, error) {
ic, region, err := c.ospInvoiceClient(ctx, cred)
if err != nil {
return nil, err
}
var out []InvoiceLine
var page *string
for {
resp, err := ic.ListInvoiceLines(ctx, ospgateway.ListInvoiceLinesRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
InternalInvoiceId: &internalInvoiceID,
Page: page,
Limit: common.Int(ospPageLimit),
})
if err != nil {
return nil, fmt.Errorf("list invoice lines: %w", err)
}
for _, item := range resp.Items {
out = append(out, toInvoiceLine(item))
}
if resp.OpcNextPage == nil {
return orEmpty(out), nil
}
page = resp.OpcNextPage
}
}
// DownloadInvoicePdf 实现 Client:整读发票 PDF,超出 maxBytes 视为异常。
func (c *RealClient) DownloadInvoicePdf(ctx context.Context, cred Credentials, internalInvoiceID string, maxBytes int64) ([]byte, error) {
ic, region, err := c.ospInvoiceClient(ctx, cred)
if err != nil {
return nil, err
}
resp, err := ic.DownloadPdfContent(ctx, ospgateway.DownloadPdfContentRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
InternalInvoiceId: &internalInvoiceID,
})
if err != nil {
return nil, fmt.Errorf("download invoice pdf: %w", err)
}
defer resp.Content.Close()
data, err := io.ReadAll(io.LimitReader(resp.Content, maxBytes+1))
if err != nil {
return nil, fmt.Errorf("download invoice pdf: %w", err)
}
if int64(len(data)) > maxBytes {
return nil, fmt.Errorf("download invoice pdf: exceeds %d bytes", maxBytes)
}
return data, nil
}
// PayInvoice 实现 Client:按订阅当前默认付款方式支付发票,email 接收回执。
func (c *RealClient) PayInvoice(ctx context.Context, cred Credentials, internalInvoiceID, email string) error {
ic, region, err := c.ospInvoiceClient(ctx, cred)
if err != nil {
return err
}
_, err = ic.PayInvoice(ctx, ospgateway.PayInvoiceRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
InternalInvoiceId: &internalInvoiceID,
PayInvoiceDetails: ospgateway.PayInvoiceDetails{Email: &email},
})
if err != nil {
return fmt.Errorf("pay invoice: %w", err)
}
return nil
}
// ListPaymentMethods 实现 Client:汇总各订阅上登记的全部付款方式。
func (c *RealClient) ListPaymentMethods(ctx context.Context, cred Credentials) ([]PaymentMethod, error) {
region, err := c.ospHomeRegion(ctx, cred)
if err != nil {
return nil, err
}
sc, err := ospgateway.NewSubscriptionServiceClientWithConfigurationProvider(provider(cred))
if err != nil {
return nil, fmt.Errorf("new osp subscription client: %w", err)
}
applyProxy(&sc.BaseClient, cred)
sc.SetRegion(region)
var out []PaymentMethod
var page *string
for {
resp, err := sc.ListSubscriptions(ctx, ospgateway.ListSubscriptionsRequest{
OspHomeRegion: &region,
CompartmentId: &cred.TenancyOCID,
Page: page,
})
if err != nil {
return nil, fmt.Errorf("list payment methods: %w", err)
}
for _, sub := range resp.Items {
out = appendPaymentMethods(out, sub.PaymentOptions)
}
if resp.OpcNextPage == nil {
return orEmpty(out), nil
}
page = resp.OpcNextPage
}
}
func toInvoice(v ospgateway.InvoiceSummary) Invoice {
inv := Invoice{
ID: deref(v.InvoiceId),
InternalID: deref(v.InternalInvoiceId),
Number: deref(v.InvoiceNumber),
Type: string(v.InvoiceType),
Status: string(v.InvoiceStatus),
Amount: money(v.InvoiceAmount),
AmountDue: money(v.InvoiceAmountDue),
TimeInvoice: sdkTime(v.TimeInvoice),
TimeDue: sdkTime(v.TimeInvoiceDue),
}
if v.IsPaid != nil {
inv.IsPaid = *v.IsPaid
}
if v.IsPayable != nil {
inv.IsPayable = *v.IsPayable
}
if v.IsPaymentFailed != nil {
inv.IsPaymentFailed = *v.IsPaymentFailed
}
if v.Currency != nil {
inv.Currency = deref(v.Currency.CurrencyCode)
}
return inv
}
func toInvoiceLine(v ospgateway.InvoiceLineSummary) InvoiceLine {
line := InvoiceLine{
Product: deref(v.Product),
OrderNo: deref(v.OrderNo),
Quantity: money(v.Quantity),
UnitPrice: money(v.NetUnitPrice),
Total: money(v.TotalPrice),
TimeStart: sdkTime(v.TimeStart),
TimeEnd: sdkTime(v.TimeEnd),
}
if v.Currency != nil {
line.Currency = deref(v.Currency.CurrencyCode)
}
return line
}
func appendPaymentMethods(out []PaymentMethod, opts []ospgateway.PaymentOption) []PaymentMethod {
for _, opt := range opts {
switch v := opt.(type) {
case ospgateway.CreditCardPaymentOption:
out = append(out, creditCardMethod(v))
case *ospgateway.CreditCardPaymentOption:
out = append(out, creditCardMethod(*v))
case ospgateway.PaypalPaymentOption:
out = append(out, paypalMethod(v))
case *ospgateway.PaypalPaymentOption:
out = append(out, paypalMethod(*v))
}
}
return out
}
func creditCardMethod(v ospgateway.CreditCardPaymentOption) PaymentMethod {
return PaymentMethod{
Method: "CREDIT_CARD",
CardType: string(v.CreditCardType),
LastDigits: deref(v.LastDigits),
NameOnCard: deref(v.NameOnCard),
TimeExpiration: sdkTime(v.TimeExpiration),
}
}
func paypalMethod(v ospgateway.PaypalPaymentOption) PaymentMethod {
return PaymentMethod{
Method: "PAYPAL",
Email: deref(v.EmailAddress),
PayerName: strings.TrimSpace(deref(v.FirstName) + " " + deref(v.LastName)),
BillingAgreement: deref(v.ExtBillingAgreementId),
}
}
// money 把 SDK 的 float32 金额转为 float64 输出;四舍五入到 4 位小数,
// 抹掉精度转换产生的二进制噪音(如 603.53 变 603.5300293)。
func money(p *float32) float64 {
if p == nil {
return 0
}
return math.Round(float64(*p)*10000) / 10000
}