Open Zap Billing when PrintIt stamps fail for credits
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:
George Lambert 2026-10-20 01:07:39 -04:00
commit 6f8796c25e
9 changed files with 167 additions and 0 deletions

View file

@ -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)

View file

@ -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 {

View file

@ -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")
}
}

View file

@ -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
} }

View file

@ -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
}

View file

@ -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)

View file

@ -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))

View file

@ -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
}

View file

@ -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")
}
}