nx_card_rewards.nx source
↩ module page · 83 lines · 4733 B
1// nx_card_rewards.nx -- the REWARDS-vs-INTEREST engine (R2+R3 of the credit-card arm). Answers the operator's
2// exact ask: earn rewards on a real spend profile, then NET them against the interest you pay if you carry a
3// balance -- so a person can SEE whether the interest is eating the rewards. No-float, integer-exact cents;
4// composes nx_money (the same fixed-point interest math the ledger + debt engines use -- DRY, Cardinal 15).
5// A card is DATA (category rates in basis points + annual fee + sign-up terms), not hardcoded (Cardinal 11),
6// so it works for ANY card. NEVER-BRICK money analog (#26): pure functions, no side effects, no auto-anything --
7// this only COMPUTES + tells the truth; a human acts. license_tier: ORIGINAL
8import "nx_money.nx"
9const K_MAGIC_10000: i64 = 10000
10
11// Rewards earned over a year on a spend profile.
12// sp[i*2] = [annual_cents, category_id] for n buckets; reward_bps[cat] = cash-equivalent basis points
13// (100 bps = 1%) for that category; base_bps applies to any category with no explicit rate.
14// Returns cash-equivalent cents. (Points cards: pass reward_bps already in cash-equivalent bps via cr_point_bps.)
15func cr_rewards_annual(sp: *i64, n: i64, reward_bps: *i64, n_rates: i64, base_bps: i64) -> i64 {
16 var total: i64 = 0
17 var i: i64 = 0
18 while i < n {
19 let amt: i64 = sp[i * 2]
20 let cat: i64 = sp[i * 2 + 1]
21 var bps: i64 = base_bps
22 if cat >= 0 { if cat < n_rates { bps = reward_bps[cat] } }
23 let r: i64 = mny_div_round(amt * bps, K_MAGIC_10000, RND_HALF_UP)
24 total = total + r
25 i = i + 1
26 }
27 return total
28}
29
30// Convert a points-earn rate to cash-equivalent basis points, valued HONESTLY.
31// points_per_dollar (x100, so 1.5x = 150) times cents_per_point (x100, so 1.25 cpp = 125) / 100 = bps.
32// Use the CASH floor for a conservative estimate, the transfer ceiling only if the person really transfers.
33func cr_point_bps(points_per_dollar_x100: i64, cents_per_point_x100: i64) -> i64 {
34 return mny_div_round(points_per_dollar_x100 * cents_per_point_x100, 100, RND_HALF_UP)
35}
36
37// First-year sign-up bonus value: the bonus only if the person will actually spend at least min_spend within
38// the window WITHOUT overspending to chase it (spend_in_window = spend they will make anyway). Else 0.
39func cr_signup_value(spend_in_window: i64, min_spend: i64, bonus: i64) -> i64 {
40 if spend_in_window >= min_spend { return bonus }
41 return 0
42}
43
44// Annual interest paid if you CARRY an average balance at a given APR (percent as num/den, e.g. 24.99% = 2499,100).
45// This is the money the rewards must beat. Composes nx_money.mny_apply_rate (exact, banker-rounded).
46func cr_interest_annual(avg_balance: i64, apr_pct_num: i64, apr_pct_den: i64) -> i64 {
47 let rate: i64 = mny_rate_from_pct(apr_pct_num, apr_pct_den)
48 return mny_apply_rate(avg_balance, rate, RND_HALF_EVEN)
49}
50
51// THE HEADLINE: net annual value of a rewards card = rewards + first-year sign-up - annual fee - interest.
52// NEGATIVE means the interest (and fees) EAT the rewards -- the "2% back" card is costing you money.
53func cr_net_annual(rewards: i64, signup_first_year: i64, annual_fee: i64, interest: i64) -> i64 {
54 return rewards + signup_first_year - annual_fee - interest
55}
56
57// The carried balance at which interest exactly cancels the annual rewards (net of fee) -- the TRAP LINE.
58// Carry more than this and the card is a net LOSS. Returns cents; -1 if there is no positive reward to cancel.
59func cr_breakeven_balance(rewards: i64, annual_fee: i64, apr_pct_num: i64, apr_pct_den: i64) -> i64 {
60 let net_reward: i64 = rewards - annual_fee
61 if net_reward <= 0 { return 0 - 1 }
62 let rate: i64 = mny_rate_from_pct(apr_pct_num, apr_pct_den)
63 if rate <= 0 { return 0 - 1 }
64 return mny_div_round(net_reward * MNY_RATE_SCALE, rate, RND_HALF_UP)
65}
66
67// The honest, true-friend verdict for THIS person (revolver-protection by construction):
68// 2 = REWARDS WIN (net positive and worth it)
69// 1 = MARGINAL (net positive but below the "worth the hassle" floor)
70// 0 = INTEREST EATS REWARDS (net <= 0 -> pay the balance down first / a no-fee low-APR card wins; say it plainly)
71// marginal_floor_cents is DATA (Cardinal 11), not a magic number.
72func cr_verdict(net: i64, marginal_floor: i64) -> i64 {
73 if net <= 0 { return 0 }
74 if net < marginal_floor { return 1 }
75 return 2
76}
77
78// The un-pushy ranking primitive: pick the option with the higher net-TO-THE-USER. No affiliate weighting exists
79// here BY CONSTRUCTION -- there is no commission input to tilt it. Returns 0 if A wins (ties -> A), 1 if B wins.
80func cr_pick(net_a: i64, net_b: i64) -> i64 {
81 if net_a >= net_b { return 0 }
82 return 1
83}