Open Zap Billing when PrintIt stamps fail for credits
Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
Some checks failed
windows-msi / full-msi-unsigned (push) Has been cancelled
On HTTP 402/429 (and credit/billing error bodies), hold the print job and open https://zap.verae.ai/portal/?tab=billing so the user can reload prepaid balance. Throttle browser opens to once per 10 minutes. Auth failures still use the sign-in flow unchanged.
This commit is contained in:
parent
7af670c4ef
commit
6f8796c25e
9 changed files with 167 additions and 0 deletions
|
|
@ -230,6 +230,25 @@ func runStamp(args []string) int {
|
||||||
handleAuthFailure(authReason(err), stampFile)
|
handleAuthFailure(authReason(err), stampFile)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
if err != nil && isBillingFailure(err) {
|
||||||
|
title := *name
|
||||||
|
if title == "" {
|
||||||
|
title = filepath.Base(pdfPath)
|
||||||
|
}
|
||||||
|
m, herr := holdJob(pdfPath, stampTarget{Title: title, OutDir: *outDir, OutPath: outPath}, "cli", err, false)
|
||||||
|
if herr != nil {
|
||||||
|
lj.Errorf("stamp failed (%v) and could not hold job: %v", err, herr)
|
||||||
|
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
|
||||||
|
fmt.Fprintf(os.Stderr, "Add funds: %s\n", auth.ZapBillingURL)
|
||||||
|
_ = openBrowser(auth.ZapBillingURL)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
lj.Printf("stamp: out of credits (%v); job HELD as pending %s — opening Billing", err, m.ID)
|
||||||
|
fmt.Printf("Out of credits; the job is saved (pending %s). Opening Billing so you can add funds:\n %s\n", m.ID, auth.ZapBillingURL)
|
||||||
|
notify(statuslang.NotifyTitle("billing"), notifyQueueHint(statuslang.NotifyBody("billing", title, m.ID)))
|
||||||
|
handleBillingFailure(billingReason(err))
|
||||||
|
return 0
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lj.Errorf("stamp failed: %v", err)
|
lj.Errorf("stamp failed: %v", err)
|
||||||
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
|
fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err)
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ var errNoKey = errors.New("no Verae signing key is set up")
|
||||||
// isAuthFailure: the job can succeed after the user signs in again.
|
// isAuthFailure: the job can succeed after the user signs in again.
|
||||||
func isAuthFailure(err error) bool { return errors.Is(err, errNoKey) || veraeapi.IsAuthError(err) }
|
func isAuthFailure(err error) bool { return errors.Is(err, errNoKey) || veraeapi.IsAuthError(err) }
|
||||||
|
|
||||||
|
// isBillingFailure: the job can succeed after the user adds funds on Zap Billing.
|
||||||
|
func isBillingFailure(err error) bool { return veraeapi.IsBillingError(err) }
|
||||||
|
|
||||||
type pendingMeta struct {
|
type pendingMeta struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
|
@ -162,6 +165,11 @@ func retryPending(stamp stampFn) (done, remaining int, authFail bool) {
|
||||||
lj.Errorf("pending: %s still not authorised: %v", m.ID, err)
|
lj.Errorf("pending: %s still not authorised: %v", m.ID, err)
|
||||||
return done, len(listPending()), true
|
return done, len(listPending()), true
|
||||||
}
|
}
|
||||||
|
if isBillingFailure(err) {
|
||||||
|
lj.Errorf("pending: %s still needs funds: %v", m.ID, err)
|
||||||
|
offerBillingCheckout(billingReason(err))
|
||||||
|
return done, len(listPending()), false
|
||||||
|
}
|
||||||
lj.Errorf("pending: %s failed (kept for next retry): %v", m.ID, err)
|
lj.Errorf("pending: %s failed (kept for next retry): %v", m.ID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -196,6 +204,35 @@ func promptLock() (func(), bool) {
|
||||||
return func() {}, false
|
return func() {}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// billingOpenThrottle is how long we suppress re-opening the Billing tab
|
||||||
|
// after a credit failure (avoids a browser storm while jobs stay held).
|
||||||
|
const billingOpenThrottle = 10 * time.Minute
|
||||||
|
|
||||||
|
// offerBillingCheckout opens the Zap Billing reload page at most once per
|
||||||
|
// billingOpenThrottle. Safe across processes via a lock file in pendingDir.
|
||||||
|
func offerBillingCheckout(reason string) {
|
||||||
|
_ = os.MkdirAll(pendingDir(), 0o700)
|
||||||
|
marker := filepath.Join(pendingDir(), ".billing.open")
|
||||||
|
if st, err := os.Stat(marker); err == nil && time.Since(st.ModTime()) < billingOpenThrottle {
|
||||||
|
vlog.Printf("billing: checkout already offered %s ago; not re-opening (%d pending)",
|
||||||
|
time.Since(st.ModTime()).Round(time.Second), len(listPending()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(marker, []byte(time.Now().Format(time.RFC3339)+"\n"+reason+"\n"), 0o600)
|
||||||
|
vlog.Printf("billing: opening %s (%d pending)", auth.ZapBillingURL, len(listPending()))
|
||||||
|
notifyUser(statuslang.NotifyTitle("billing"), notifyQueueHint(reason))
|
||||||
|
if err := openBrowser(auth.ZapBillingURL); err != nil {
|
||||||
|
vlog.Errorf("billing: could not open browser: %v — open %s", err, auth.ZapBillingURL)
|
||||||
|
fmt.Fprintf(os.Stderr, "Out of credits. Open Billing to add funds:\n %s\n", auth.ZapBillingURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBillingFailure holds the print job narrative for the user and opens
|
||||||
|
// Zap Billing. Does not prompt for a new signing key.
|
||||||
|
func handleBillingFailure(reason string) {
|
||||||
|
offerBillingCheckout(reason)
|
||||||
|
}
|
||||||
|
|
||||||
// handleAuthFailure shows ONE login prompt (if none is open), then retries all
|
// handleAuthFailure shows ONE login prompt (if none is open), then retries all
|
||||||
// pending jobs. Returns true when everything pending was stamped.
|
// pending jobs. Returns true when everything pending was stamped.
|
||||||
func handleAuthFailure(reason string, stamp stampFn) bool {
|
func handleAuthFailure(reason string, stamp stampFn) bool {
|
||||||
|
|
|
||||||
|
|
@ -91,3 +91,17 @@ func TestRetryKeepsNonAuthFailures(t *testing.T) {
|
||||||
t.Fatalf("done=%d remaining=%d authFail=%v", done, remaining, authFail)
|
t.Fatalf("done=%d remaining=%d authFail=%v", done, remaining, authFail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsBillingFailure(t *testing.T) {
|
||||||
|
pendingEnv(t)
|
||||||
|
bill := &veraeapi.HTTPError{Service: "zapier stamp", Status: 402, Body: `{"error":"payment_required"}`}
|
||||||
|
if !isBillingFailure(bill) {
|
||||||
|
t.Fatal("expected billing failure")
|
||||||
|
}
|
||||||
|
if isAuthFailure(bill) {
|
||||||
|
t.Fatal("402 must not be auth failure")
|
||||||
|
}
|
||||||
|
if isBillingFailure(&veraeapi.HTTPError{Status: 401, Body: "no"}) {
|
||||||
|
t.Fatal("401 is not billing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,18 @@ func makeProcessor(outDir string) winprint.Processor {
|
||||||
go handleAuthFailure(authReason(err), stampFile)
|
go handleAuthFailure(authReason(err), stampFile)
|
||||||
return nil // the job is safe; IPP reports success to Windows
|
return nil // the job is safe; IPP reports success to Windows
|
||||||
}
|
}
|
||||||
|
if isBillingFailure(err) {
|
||||||
|
m, herr := holdJob(j.Path, target, j.Source, err, true)
|
||||||
|
if herr != nil {
|
||||||
|
lj.Errorf("process: could not hold job: %v (PDF left at %s)", herr, j.Path)
|
||||||
|
go offerBillingCheckout(billingReason(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lj.Printf("process: HELD as pending %s (%v); opening Zap Billing", m.ID, err)
|
||||||
|
notifyUser(statuslang.NotifyTitle("billing"), notifyQueueHint(statuslang.NotifyBody("billing", m.Title, m.ID)))
|
||||||
|
go handleBillingFailure(billingReason(err))
|
||||||
|
return nil // job held; user adds funds then reprint / retry pending
|
||||||
|
}
|
||||||
notifyUser(statuslang.NotifyTitle("failed"), statuslang.NotifyBody("failed", j.Title, err.Error()))
|
notifyUser(statuslang.NotifyTitle("failed"), statuslang.NotifyBody("failed", j.Title, err.Error()))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
||||||
|
|
@ -59,3 +60,18 @@ func authReason(err error) string {
|
||||||
}
|
}
|
||||||
return "No Verae signing key is set up for this user yet. Please sign in or create an account."
|
return "No Verae signing key is set up for this user yet. Please sign in or create an account."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func billingReason(err error) string {
|
||||||
|
if err != nil && strings.TrimSpace(err.Error()) != "" {
|
||||||
|
return "Your Verae account needs funds before PrintIt can stamp. Opening Billing so you can add prepaid balance.\n\n(" + shortErr(err) + ")"
|
||||||
|
}
|
||||||
|
return "Your Verae account needs funds before PrintIt can stamp. Opening Billing so you can add prepaid balance."
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortErr(err error) string {
|
||||||
|
s := err.Error()
|
||||||
|
if len(s) > 160 {
|
||||||
|
return s[:157] + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,9 @@ func PreferredChannel() Channel {
|
||||||
const (
|
const (
|
||||||
KeychainService = "verae.xVeraeToken"
|
KeychainService = "verae.xVeraeToken"
|
||||||
PortalSignupURL = "https://customer.verae.ai/portal/"
|
PortalSignupURL = "https://customer.verae.ai/portal/"
|
||||||
|
// ZapBillingURL is the prepaid reload / Add funds tab on the Zap portal
|
||||||
|
// (PrintIt’s commercial meter). Open this when stamps fail for credits.
|
||||||
|
ZapBillingURL = "https://zap.verae.ai/portal/?tab=billing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SaveXVeraeToken stores the customer signing key in ~/.verae/xverae-token (0600)
|
// SaveXVeraeToken stores the customer signing key in ~/.verae/xverae-token (0600)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const (
|
||||||
// Notify / tray short titles (not chip labels; still brand-aligned).
|
// Notify / tray short titles (not chip labels; still brand-aligned).
|
||||||
FinalReceiptReady = "Final receipt ready"
|
FinalReceiptReady = "Final receipt ready"
|
||||||
SignInToFinish = "Sign in to finish timestamp"
|
SignInToFinish = "Sign in to finish timestamp"
|
||||||
|
OutOfCredits = "Out of credits — add funds"
|
||||||
PrinterReady = "Printer ready"
|
PrinterReady = "Printer ready"
|
||||||
|
|
||||||
// EvidenceNote is the standing disclaimer for queue / tray UI.
|
// EvidenceNote is the standing disclaimer for queue / tray UI.
|
||||||
|
|
@ -158,6 +159,8 @@ func NotifyTitle(state string) string {
|
||||||
return Action
|
return Action
|
||||||
case "held", "signin":
|
case "held", "signin":
|
||||||
return SignInToFinish
|
return SignInToFinish
|
||||||
|
case "billing", "credits", "payment":
|
||||||
|
return OutOfCredits
|
||||||
case "final", "completed", "ok":
|
case "final", "completed", "ok":
|
||||||
return FinalReceiptReady
|
return FinalReceiptReady
|
||||||
default:
|
default:
|
||||||
|
|
@ -181,6 +184,11 @@ func NotifyBody(state, title, extra string) string {
|
||||||
return fmt.Sprintf("%s · %s · hold %s — sign in to stamp", t, Pending, short(extra))
|
return fmt.Sprintf("%s · %s · hold %s — sign in to stamp", t, Pending, short(extra))
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s · %s — sign in to stamp", t, Pending)
|
return fmt.Sprintf("%s · %s — sign in to stamp", t, Pending)
|
||||||
|
case "billing", "credits", "payment":
|
||||||
|
if extra != "" {
|
||||||
|
return fmt.Sprintf("%s · %s · hold %s — open Billing to add funds", t, OutOfCredits, short(extra))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s · %s — open Billing to add funds", t, OutOfCredits)
|
||||||
case "failed", "action":
|
case "failed", "action":
|
||||||
if extra != "" {
|
if extra != "" {
|
||||||
return fmt.Sprintf("%s · %s · %s", t, Failed, short(extra))
|
return fmt.Sprintf("%s · %s · %s", t, Failed, short(extra))
|
||||||
|
|
|
||||||
|
|
@ -394,3 +394,38 @@ func IsAuthError(err error) bool {
|
||||||
var he *HTTPError
|
var he *HTTPError
|
||||||
return errors.As(err, &he) && (he.Status == 401 || he.Status == 403)
|
return errors.As(err, &he) && (he.Status == 401 || he.Status == 403)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsBillingError reports whether err means the account needs funds / billing action
|
||||||
|
// (HTTP 402 Payment Required, 429 rate/quota, or a body that names credits/billing).
|
||||||
|
// Callers should open auth.ZapBillingURL so the user can reload prepaid balance.
|
||||||
|
func IsBillingError(err error) bool {
|
||||||
|
var he *HTTPError
|
||||||
|
if !errors.As(err, &he) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if he.Status == 402 || he.Status == 429 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
b := strings.ToLower(he.Body)
|
||||||
|
for _, needle := range []string{
|
||||||
|
"payment_required",
|
||||||
|
"payment required",
|
||||||
|
"insufficient_credit",
|
||||||
|
"insufficient credit",
|
||||||
|
"insufficient_balance",
|
||||||
|
"out of credit",
|
||||||
|
"out_of_credit",
|
||||||
|
"no credits",
|
||||||
|
"billing_failed",
|
||||||
|
"billing hold",
|
||||||
|
"access_paused",
|
||||||
|
"access paused",
|
||||||
|
"quota exceeded",
|
||||||
|
"quota_exceeded",
|
||||||
|
} {
|
||||||
|
if strings.Contains(b, needle) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,3 +76,26 @@ func TestStampZapierAttachPdf(t *testing.T) {
|
||||||
t.Fatalf("%+v", r)
|
t.Fatalf("%+v", r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsBillingError(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{nil, false},
|
||||||
|
{&HTTPError{Service: "zapier stamp", Status: 402, Body: `{"error":"payment_required"}`}, true},
|
||||||
|
{&HTTPError{Service: "zapier stamp", Status: 429, Body: "rate limit"}, true},
|
||||||
|
{&HTTPError{Service: "zapier stamp", Status: 403, Body: "billing_failed: no credits"}, true},
|
||||||
|
{&HTTPError{Service: "zapier stamp", Status: 500, Body: "out of credit"}, true},
|
||||||
|
{&HTTPError{Service: "zapier stamp", Status: 401, Body: "unauthorized"}, false},
|
||||||
|
{&HTTPError{Service: "zapier stamp", Status: 500, Body: "upstream timeout"}, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := IsBillingError(c.err); got != c.want {
|
||||||
|
t.Errorf("IsBillingError(%v)=%v want %v", c.err, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if IsAuthError(&HTTPError{Status: 402, Body: "x"}) {
|
||||||
|
t.Fatal("402 must not be auth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue