@go-avro/avro-js 0.0.48 → 0.0.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ import { PaymentType } from '../types/api/PaymentType';
2
+ export interface PassOnFeeBreakdown {
3
+ /** Stripe processing fee on the grossed-up amount, in cents. */
4
+ stripeFee: number;
5
+ /** Avro application fee on the net (gross - stripeFee), in cents. */
6
+ avroFee: number;
7
+ /** Total fee added on top of the subtotal: stripeFee + avroFee. */
8
+ totalFee: number;
9
+ /** Gross amount the customer is charged: subtotal + totalFee. */
10
+ gross: number;
11
+ }
12
+ /**
13
+ * Compute Stripe's processing fee on a gross amount, in cents.
14
+ * Ceiling matches Stripe's worst-case rounding so the merchant is never short.
15
+ */
16
+ export declare function computeStripeFee(grossCents: number, method: PaymentType): number;
17
+ /**
18
+ * Compute Avro's application fee on a net amount (gross - stripeFee), in cents.
19
+ */
20
+ export declare function computeAvroFee(netCents: number): number;
21
+ /**
22
+ * Compute the pass-on fee (in cents) such that, after Stripe and Avro deduct
23
+ * their fees from the gross amount the customer is charged, the merchant
24
+ * receives at least `subtotalCents`.
25
+ *
26
+ * Returns `ZERO_FEE` when `subtotalCents <= 0`.
27
+ *
28
+ * Closed form: a finite set of candidate grosses is derived from each fee
29
+ * regime (avro at the 5¢ floor vs. percentage; ACH stripe at the $5 cap vs.
30
+ * percentage). The smallest candidate that satisfies the invariant
31
+ * `gross - stripeFee(gross) - avroFee(gross - stripeFee(gross)) >= subtotal`
32
+ * is selected. No iteration.
33
+ */
34
+ export declare function computePassOnFee(subtotalCents: number, method: PaymentType): PassOnFeeBreakdown;
35
+ /**
36
+ * Pick the payment method to use when computing the persisted ("default")
37
+ * fee line item amount on a bill. Card if enabled (worst case for the
38
+ * customer), otherwise ACH, otherwise null (no fee should be persisted).
39
+ */
40
+ export declare function defaultPassOnFeeMethod(enabledPaymentMethods: PaymentType[] | null | undefined): PaymentType | null;
@@ -0,0 +1,155 @@
1
+ import { PaymentType } from '../types/api/PaymentType';
2
+ /**
3
+ * Pass-on (surcharge) fee calculations.
4
+ *
5
+ * When `pass_on_fees` is enabled on a bill, the customer is charged a gross
6
+ * amount that, after Stripe's processing fees and Avro's application fee, leaves
7
+ * the merchant with the bill's subtotal. The fee added on top of the subtotal
8
+ * is exposed as a single read-only line item on the bill.
9
+ *
10
+ * Stripe US standard pricing (online):
11
+ * - Card: 2.9% of gross + 30¢
12
+ * - ACH (us_bank): 0.8% of gross, capped at $5.00
13
+ * Avro application fee:
14
+ * - 0.4% of net (gross - stripe), minimum 5¢
15
+ *
16
+ * All amounts are integer cents. All math is closed-form (no iteration).
17
+ *
18
+ * The integer-ceiling on the gross-up guarantees the merchant nets at
19
+ * least the requested subtotal. In rare regime-boundary cases the merchant
20
+ * may net a sub-cent more than requested — never less.
21
+ */
22
+ // --- Rate constants (per 1000 for integer math where applicable) ---
23
+ const CARD_PCT_PER_1000 = 29; // 2.9%
24
+ const CARD_FIXED_CENTS = 30; // 30¢
25
+ const ACH_PCT_PER_1000 = 8; // 0.8%
26
+ const ACH_CAP_CENTS = 500; // $5.00
27
+ const AVRO_PCT_PER_1000 = 4; // 0.4%
28
+ const AVRO_MIN_CENTS = 5; // 5¢
29
+ const ZERO_FEE = {
30
+ stripeFee: 0,
31
+ avroFee: 0,
32
+ totalFee: 0,
33
+ gross: 0,
34
+ };
35
+ /**
36
+ * Compute Stripe's processing fee on a gross amount, in cents.
37
+ * Ceiling matches Stripe's worst-case rounding so the merchant is never short.
38
+ */
39
+ export function computeStripeFee(grossCents, method) {
40
+ if (grossCents <= 0)
41
+ return 0;
42
+ if (method === PaymentType.CARD) {
43
+ return Math.ceil((CARD_PCT_PER_1000 * grossCents) / 1000) + CARD_FIXED_CENTS;
44
+ }
45
+ // us_bank_account: percentage capped at $5.00
46
+ return Math.min(Math.ceil((ACH_PCT_PER_1000 * grossCents) / 1000), ACH_CAP_CENTS);
47
+ }
48
+ /**
49
+ * Compute Avro's application fee on a net amount (gross - stripeFee), in cents.
50
+ */
51
+ export function computeAvroFee(netCents) {
52
+ if (netCents <= 0)
53
+ return AVRO_MIN_CENTS;
54
+ return Math.max(Math.ceil((AVRO_PCT_PER_1000 * netCents) / 1000), AVRO_MIN_CENTS);
55
+ }
56
+ /**
57
+ * Compute the pass-on fee (in cents) such that, after Stripe and Avro deduct
58
+ * their fees from the gross amount the customer is charged, the merchant
59
+ * receives at least `subtotalCents`.
60
+ *
61
+ * Returns `ZERO_FEE` when `subtotalCents <= 0`.
62
+ *
63
+ * Closed form: a finite set of candidate grosses is derived from each fee
64
+ * regime (avro at the 5¢ floor vs. percentage; ACH stripe at the $5 cap vs.
65
+ * percentage). The smallest candidate that satisfies the invariant
66
+ * `gross - stripeFee(gross) - avroFee(gross - stripeFee(gross)) >= subtotal`
67
+ * is selected. No iteration.
68
+ */
69
+ export function computePassOnFee(subtotalCents, method) {
70
+ if (subtotalCents <= 0)
71
+ return { ...ZERO_FEE };
72
+ const B = subtotalCents;
73
+ const candidates = method === PaymentType.CARD ? cardCandidates(B) : achCandidates(B);
74
+ let best = Number.POSITIVE_INFINITY;
75
+ for (const g of candidates) {
76
+ const stripe = computeStripeFee(g, method);
77
+ const avro = computeAvroFee(g - stripe);
78
+ if (g - stripe - avro >= B && g < best) {
79
+ best = g;
80
+ }
81
+ }
82
+ if (!Number.isFinite(best)) {
83
+ // Should never happen — the candidate set covers every regime — but
84
+ // fall back to a safe overshoot rather than throwing.
85
+ best = B + Math.ceil(B * 0.05) + CARD_FIXED_CENTS + AVRO_MIN_CENTS;
86
+ }
87
+ const stripeFee = computeStripeFee(best, method);
88
+ const avroFee = computeAvroFee(best - stripeFee);
89
+ return {
90
+ stripeFee,
91
+ avroFee,
92
+ totalFee: best - B,
93
+ gross: best,
94
+ };
95
+ }
96
+ /**
97
+ * Card regime candidates.
98
+ *
99
+ * Stripe = ceil(0.029 * G) + 30; avro = max(ceil(0.004 * (G - stripe)), 5).
100
+ *
101
+ * The two `ceil`s can each add up to 1¢ relative to the exact rational form,
102
+ * so each candidate carries a baked-in 2¢ (percentage regime) or 1¢ (floor
103
+ * regime) safety margin to guarantee the invariant holds for every B in
104
+ * integer cents — no post-hoc iteration required.
105
+ *
106
+ * 1) Percentage avro: G * 0.967116 ≥ B + 29.88 + 2
107
+ * => G = ceil((1_000_000 * B + 31_880_000) / 967_116)
108
+ * 2) Floor avro (5¢): G * 0.971 ≥ B + 35 + 1
109
+ * => G = ceil((1000 * B + 36_000) / 971)
110
+ */
111
+ function cardCandidates(B) {
112
+ return [
113
+ Math.ceil((1000000 * B + 31880000) / 967116),
114
+ Math.ceil((1000 * B + 36000) / 971),
115
+ ];
116
+ }
117
+ /**
118
+ * ACH regime candidates.
119
+ *
120
+ * Stripe = min(ceil(0.008 * G), 500); avro = max(ceil(0.004 * (G - stripe)), 5).
121
+ *
122
+ * Same ceiling-safety logic as card. Stripe at the $5 cap is a fixed integer
123
+ * so its ceiling-slack disappears in the capped regimes.
124
+ *
125
+ * 1) No-cap percentage avro: G * 0.988032 ≥ B + 2
126
+ * => G = ceil((1_000_000 * (B + 2)) / 988_032)
127
+ * 2) No-cap floor avro: G * 0.992 ≥ B + 5 + 1
128
+ * => G = ceil((1000 * (B + 6)) / 992)
129
+ * 3) Capped percentage avro: G * 0.996 ≥ B + 498 + 1
130
+ * => G = ceil((1000 * (B + 499)) / 996)
131
+ * 4) Capped floor avro: G = B + 505
132
+ */
133
+ function achCandidates(B) {
134
+ return [
135
+ Math.ceil((1000000 * (B + 2)) / 988032),
136
+ Math.ceil((1000 * (B + 6)) / 992),
137
+ Math.ceil((1000 * (B + 499)) / 996),
138
+ B + 505,
139
+ ];
140
+ }
141
+ /**
142
+ * Pick the payment method to use when computing the persisted ("default")
143
+ * fee line item amount on a bill. Card if enabled (worst case for the
144
+ * customer), otherwise ACH, otherwise null (no fee should be persisted).
145
+ */
146
+ export function defaultPassOnFeeMethod(enabledPaymentMethods) {
147
+ if (!enabledPaymentMethods || enabledPaymentMethods.length === 0)
148
+ return null;
149
+ if (enabledPaymentMethods.includes(PaymentType.CARD))
150
+ return PaymentType.CARD;
151
+ if (enabledPaymentMethods.includes(PaymentType.US_BANK_ACCOUNT)) {
152
+ return PaymentType.US_BANK_ACCOUNT;
153
+ }
154
+ return null;
155
+ }
@@ -9,17 +9,9 @@ import { CacheData } from '../types/cache';
9
9
  import { Waiver } from '../types/api/Waiver';
10
10
  import type { EmailSucceededPayload, EmailFailedPayload, EmailType } from '../types/api/EmailNotification';
11
11
  import type { BulkDeleteBillsResponse, BulkEmailBillsResponse } from '../client/hooks/bills';
12
- /** Callbacks for a tracked email request. */
13
12
  export interface TrackEmailOptions {
14
13
  emailType?: EmailType;
15
- /**
16
- * Pre-existing request_id to register. When omitted, a fresh uuid is
17
- * generated. Pass this when the request_id was produced elsewhere
18
- * (e.g. a bulk endpoint returns one in its synchronous response, or
19
- * the caller wants to pre-generate it and ship it in the POST body).
20
- */
21
14
  requestId?: string;
22
- /** How long to wait before firing onTimeout (ms). Default 30 000. */
23
15
  timeout?: number;
24
16
  onSuccess?: (data: EmailSucceededPayload) => void;
25
17
  onFailure?: (data: EmailFailedPayload) => void;
@@ -757,20 +749,7 @@ export declare class AvroQueryClient {
757
749
  query?: string;
758
750
  offset?: number;
759
751
  }, cancelToken?: CancelToken, headers?: Record<string, string>): Promise<any>;
760
- /**
761
- * Lazily register socket listeners for email_succeeded / email_failed.
762
- * Listeners live on the socket (not in a React effect) so they survive
763
- * component unmounts.
764
- */
765
752
  private _initEmailListeners;
766
- /**
767
- * Track an outbound email request.
768
- *
769
- * Generates a `request_id`, registers socket listeners (once), and returns
770
- * the ID so callers can pass it in the HTTP body. The backend emits
771
- * `email_succeeded` / `email_failed` to the user's GUID room; this method
772
- * correlates the event by `request_id` and fires the appropriate callback.
773
- */
774
753
  trackEmail(options?: TrackEmailOptions): string;
775
754
  sendEmail(emailId: string, formData: FormData, progressUpdateCallback?: (loaded: number, total: number) => void): Promise<void>;
776
755
  sendBillEmail(billId: string, body?: {
@@ -1100,12 +1100,6 @@ export class AvroQueryClient {
1100
1100
  throw new StandardError(500, 'Failed to fetch sessions');
1101
1101
  });
1102
1102
  }
1103
- /* ── Email delivery tracking ──────────────────────────────────────── */
1104
- /**
1105
- * Lazily register socket listeners for email_succeeded / email_failed.
1106
- * Listeners live on the socket (not in a React effect) so they survive
1107
- * component unmounts.
1108
- */
1109
1103
  _initEmailListeners() {
1110
1104
  if (this._emailListenersInit)
1111
1105
  return;
@@ -1114,7 +1108,6 @@ export class AvroQueryClient {
1114
1108
  const entry = this._emailTracking.get(data.request_id);
1115
1109
  if (!entry)
1116
1110
  return;
1117
- // Reset timeout — more recipients may follow for the same request_id
1118
1111
  clearTimeout(entry.timerId);
1119
1112
  entry.timerId = setTimeout(() => {
1120
1113
  this._emailTracking.delete(data.request_id);
@@ -1132,21 +1125,10 @@ export class AvroQueryClient {
1132
1125
  entry.onFailure?.(data);
1133
1126
  });
1134
1127
  }
1135
- /**
1136
- * Track an outbound email request.
1137
- *
1138
- * Generates a `request_id`, registers socket listeners (once), and returns
1139
- * the ID so callers can pass it in the HTTP body. The backend emits
1140
- * `email_succeeded` / `email_failed` to the user's GUID room; this method
1141
- * correlates the event by `request_id` and fires the appropriate callback.
1142
- */
1143
1128
  trackEmail(options = {}) {
1144
1129
  this._initEmailListeners();
1145
1130
  const requestId = options.requestId ?? uuidv4();
1146
1131
  const { timeout = 30000 } = options;
1147
- // If this request_id is already being tracked, replace the existing
1148
- // entry so the latest caller's handlers win. (Callers that reuse a
1149
- // request_id are explicitly opting in to this behaviour.)
1150
1132
  const existing = this._emailTracking.get(requestId);
1151
1133
  if (existing) {
1152
1134
  clearTimeout(existing.timerId);
@@ -5,43 +5,15 @@ export interface EmailResult {
5
5
  status: EmailResultStatus;
6
6
  emailType?: EmailType;
7
7
  recipient?: string;
8
- /**
9
- * Bill GUID when the event came from a bill email — single or bulk.
10
- * Use this to correlate per-bill outcomes when one `requestId`
11
- * covers many bills (`POST /company/<id>/bills/bulk`).
12
- */
13
8
  billGuid?: string;
14
9
  error?: EmailFailedPayload['error'];
15
10
  }
16
11
  export interface UseEmailStatusOptions {
17
- /** How long to wait for a socket event before firing `onTimeout` (ms). Default 30 000. */
18
12
  timeout?: number;
19
- /** Called when the backend reports success. */
20
13
  onSuccess?: (result: EmailResult) => void;
21
- /** Called when the backend reports failure. */
22
14
  onFailure?: (result: EmailResult) => void;
23
- /** Called when neither success nor failure arrives within the timeout window. */
24
15
  onTimeout?: (requestId: string) => void;
25
16
  }
26
- /**
27
- * Subscribe to backend email delivery notifications via Socket.IO.
28
- *
29
- * Delegates to `AvroQueryClient.trackEmail()` which registers socket
30
- * listeners on the client itself (not in a React effect), so
31
- * notifications survive component unmounts.
32
- *
33
- * Usage:
34
- * ```ts
35
- * const { trackEmail, pending, results } = useEmailStatus({
36
- * onSuccess: (r) => toast.success(`Email sent to ${r.recipient}`),
37
- * onFailure: (r) => toast.error(`Email failed: ${r.error?.message}`),
38
- * onTimeout: (id) => toast.warn("Email status unknown"),
39
- * });
40
- *
41
- * const requestId = trackEmail("bill");
42
- * await avroQueryClient.sendBillingEmail({ billId, request_id: requestId });
43
- * ```
44
- */
45
17
  export declare function useEmailStatus(options?: UseEmailStatusOptions): {
46
18
  readonly trackEmail: (emailType?: EmailType, opts?: {
47
19
  requestId?: string;
@@ -1,41 +1,16 @@
1
1
  import { useCallback, useRef, useState } from 'react';
2
2
  import { useAvroQueryClient } from '../../client/AvroQueryClientProvider';
3
- /* ──────────────────────────────────────────────────────────────────────── */
4
- /* Hook */
5
- /* ──────────────────────────────────────────────────────────────────────── */
6
- /**
7
- * Subscribe to backend email delivery notifications via Socket.IO.
8
- *
9
- * Delegates to `AvroQueryClient.trackEmail()` which registers socket
10
- * listeners on the client itself (not in a React effect), so
11
- * notifications survive component unmounts.
12
- *
13
- * Usage:
14
- * ```ts
15
- * const { trackEmail, pending, results } = useEmailStatus({
16
- * onSuccess: (r) => toast.success(`Email sent to ${r.recipient}`),
17
- * onFailure: (r) => toast.error(`Email failed: ${r.error?.message}`),
18
- * onTimeout: (id) => toast.warn("Email status unknown"),
19
- * });
20
- *
21
- * const requestId = trackEmail("bill");
22
- * await avroQueryClient.sendBillingEmail({ billId, request_id: requestId });
23
- * ```
24
- */
25
3
  export function useEmailStatus(options = {}) {
26
4
  const { timeout = 30000 } = options;
27
5
  const client = useAvroQueryClient();
28
- // Use refs for callbacks so the client-level handlers always see the latest
29
6
  const onSuccessRef = useRef(options.onSuccess);
30
7
  const onFailureRef = useRef(options.onFailure);
31
8
  const onTimeoutRef = useRef(options.onTimeout);
32
9
  onSuccessRef.current = options.onSuccess;
33
10
  onFailureRef.current = options.onFailure;
34
11
  onTimeoutRef.current = options.onTimeout;
35
- // Expose reactive state so the UI can render pending / completed
36
12
  const [pending, setPending] = useState(new Map());
37
13
  const [results, setResults] = useState(new Map());
38
- /* ── trackEmail — delegates to client.trackEmail() ──────────────── */
39
14
  const trackEmail = useCallback((emailType, opts = {}) => {
40
15
  const requestId = client.trackEmail({
41
16
  emailType,
package/dist/index.d.ts CHANGED
@@ -32,6 +32,8 @@ import './client/hooks/proposal';
32
32
  import './client/hooks/timecards';
33
33
  import './client/hooks/waivers';
34
34
  import './client/hooks/email';
35
+ export { computePassOnFee, computeStripeFee, computeAvroFee, defaultPassOnFeeMethod, } from './billing/fees';
36
+ export type { PassOnFeeBreakdown } from './billing/fees';
35
37
  export * from './types/api';
36
38
  export * from './types/auth';
37
39
  export * from './types/cache';
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ import './client/hooks/proposal';
30
30
  import './client/hooks/timecards';
31
31
  import './client/hooks/waivers';
32
32
  import './client/hooks/email';
33
+ export { computePassOnFee, computeStripeFee, computeAvroFee, defaultPassOnFeeMethod, } from './billing/fees';
33
34
  export * from './types/api';
34
35
  export * from './types/auth';
35
36
  export * from './types/cache';
@@ -2,6 +2,8 @@ import { BillPayment } from '../../types/api/BillPayment';
2
2
  import { BillUser } from '../../types/api/BillUser';
3
3
  import { CustomLineItem } from '../../types/api/CustomLineItem';
4
4
  import { PaymentType } from '../../types/api/PaymentType';
5
+ import { ProcessingFeeLineItem } from '../../types/api/ProcessingFeeLineItem';
6
+ export type BillLineItem = CustomLineItem | ProcessingFeeLineItem;
5
7
  export declare const BillStatus: {
6
8
  readonly SENT: "SENT";
7
9
  readonly PAID: "PAID";
@@ -27,8 +29,9 @@ export interface Bill {
27
29
  intent_created_at: number;
28
30
  intent_last_created_at: number;
29
31
  payments: BillPayment[];
30
- line_items: CustomLineItem[];
32
+ line_items: BillLineItem[];
31
33
  prepayments: string[];
32
34
  months: string[];
33
35
  due_date: number;
36
+ pass_on_fees: boolean;
34
37
  }
@@ -58,4 +58,5 @@ export interface Company {
58
58
  next_payment_due: number | null;
59
59
  timecard_base: number;
60
60
  timecard_length: number;
61
+ default_pass_on_fees: boolean;
61
62
  }
@@ -14,6 +14,7 @@ export declare const LineItemType: {
14
14
  readonly EVENT: "EVENT";
15
15
  readonly SERVICE_MONTH: "SERVICE_MONTH";
16
16
  readonly PREPAYMENT: "PREPAYMENT";
17
+ readonly PROCESSING_FEE: "PROCESSING_FEE";
17
18
  };
18
19
  export type LineItemType = (typeof LineItemType)[keyof typeof LineItemType];
19
20
  declare module '../../types/api/LineItem' {
@@ -13,6 +13,7 @@ export const LineItemType = {
13
13
  EVENT: 'EVENT',
14
14
  SERVICE_MONTH: 'SERVICE_MONTH',
15
15
  PREPAYMENT: 'PREPAYMENT',
16
+ PROCESSING_FEE: 'PROCESSING_FEE',
16
17
  };
17
18
  export class LineItem {
18
19
  constructor(init) {
@@ -0,0 +1,21 @@
1
+ import { LineItem } from '../../types/api/LineItem';
2
+ import { PaymentType } from '../../types/api/PaymentType';
3
+ /**
4
+ * A read-only line item populated by the backend when the bill has
5
+ * `pass_on_fees` enabled. Represents the Stripe + Avro processing fees being
6
+ * passed on to the end customer.
7
+ *
8
+ * Merchants cannot edit, add, or delete these rows directly; they are managed
9
+ * by the server (created on bill save, recomputed when the payment method
10
+ * changes at checkout, and reconciled to the actual collected fees on
11
+ * `payment_intent.succeeded`).
12
+ *
13
+ * `payment_method_assumption` records which method was used to compute the
14
+ * current `cost`. UI surfaces (invoice details, email PDF, QB sync) display
15
+ * this value so the merchant knows whether the persisted figure is the card
16
+ * estimate, the ACH estimate, or the actual reconciled amount.
17
+ */
18
+ export interface ProcessingFeeLineItem extends LineItem {
19
+ line_item_type: 'PROCESSING_FEE';
20
+ payment_method_assumption: PaymentType | null;
21
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -25,6 +25,7 @@ export * from '../types/api/PaymentType';
25
25
  export * from '../types/api/Plan';
26
26
  export * from '../types/api/PlanPayment';
27
27
  export * from '../types/api/Prepayment';
28
+ export * from '../types/api/ProcessingFeeLineItem';
28
29
  export * from '../types/api/Reaction';
29
30
  export * from '../types/api/Route';
30
31
  export * from '../types/api/RouteJob';
package/dist/types/api.js CHANGED
@@ -24,6 +24,7 @@ export * from '../types/api/PaymentType';
24
24
  export * from '../types/api/Plan';
25
25
  export * from '../types/api/PlanPayment';
26
26
  export * from '../types/api/Prepayment';
27
+ export * from '../types/api/ProcessingFeeLineItem';
27
28
  export * from '../types/api/Reaction';
28
29
  export * from '../types/api/Route';
29
30
  export * from '../types/api/RouteJob';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@go-avro/avro-js",
3
- "version": "0.0.48",
3
+ "version": "0.0.50",
4
4
  "description": "JS client for Avro backend integration.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",