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: ®ion, 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: ®ion, 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: ®ion, 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: ®ion, 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: ®ion, 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 }