code wiki / (root) / nx_card_rewards.nx

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}