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)
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 {
lj.Errorf("stamp failed: %v", 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.
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 {
ID string `json:"id"`
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)
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)
continue
}
@ -196,6 +204,35 @@ func promptLock() (func(), bool) {
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
// pending jobs. Returns true when everything pending was stamped.
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)
}
}
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)
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()))
return err
}

View file

@ -2,6 +2,7 @@ package main
import (
"fmt"
"strings"
"time"
"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."
}
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 (
KeychainService = "verae.xVeraeToken"
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)

View file

@ -20,6 +20,7 @@ const (
// Notify / tray short titles (not chip labels; still brand-aligned).
FinalReceiptReady = "Final receipt ready"
SignInToFinish = "Sign in to finish timestamp"
OutOfCredits = "Out of credits — add funds"
PrinterReady = "Printer ready"
// EvidenceNote is the standing disclaimer for queue / tray UI.
@ -158,6 +159,8 @@ func NotifyTitle(state string) string {
return Action
case "held", "signin":
return SignInToFinish
case "billing", "credits", "payment":
return OutOfCredits
case "final", "completed", "ok":
return FinalReceiptReady
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 — 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":
if 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
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)
}
}
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")
}
}