Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

267 linesCodeBlameRaw
1//! Tax: what is paid with a payment on top of what reaches the workspace's
2//! balance.
3//!
4//! - **Tax.** Stripe Tax works it out on every Checkout page, subscription,
5//! invoice and off-session charge (`stripe::checkout_tax_fields`,
6//! `invoice_tax_fields`, `tax_calculation`), at g1t's tax code, every
7//! price excluding tax. Prices on g1t are shown before tax.
8//! - **No card fee.** Stripe's fee for taking a card is part of g1t's
9//! prices (the plan's price and the margin on usage), never a line of its
10//! own on anything. Payments from before that may still carry a
11//! `card_fee` row in `tax_and_fees`; those are kept and shown as they
12//! were, and every new payment records none.
13//! - **Tax is not revenue.** A payment's balance credit is what it paid
14//! less its tax; tax is kept in `tax_and_fees`, shown as its own line on
15//! the statement, and in sudo's Costs as tax collected, never as cash.
16//! - **No address.** When Stripe Tax cannot place a customer, g1t does not
17//! charge: it marks the account (`accounts.tax_address_needed_at`), tells
18//! the owners once, and the Billing page asks for the address. Saving
19//! billing details with one clears it.
20
21use g1t_contracts::time::rfc3339;
22use g1t_kit::now_ms;
23use serde::Deserialize;
24use worker::Result;
25
26use crate::Billing;
27
28/// A payment's tax and card fee, in cents, apart from what it paid for. The
29/// fee is only ever non-zero on payments from before it was folded into
30/// prices.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
32pub(crate) struct Extras {
33 pub tax_cents: i64,
34 pub fee_cents: i64,
35}
36
37/// What a prepayment credits: what its lines came to before tax, less the
38/// card fee line, in cents. Never less than nothing.
39pub(crate) fn prepay_credit_cents(before_tax_cents: i64, fee_cents: i64) -> i64 {
40 (before_tax_cents - fee_cents.max(0)).max(0)
41}
42
43/// What of a refund came off the balance, and what was tax and fee given
44/// back with it: the refund split in the proportion the payment was.
45/// `extras_cents` is the payment's tax and fee together.
46pub(crate) fn refund_split(refunded_cents: i64, paid_cents: i64, tax_cents: i64, fee_cents: i64) -> (i64, i64, i64) {
47 if paid_cents <= 0 || refunded_cents <= 0 {
48 return (refunded_cents.max(0), 0, 0);
49 }
50 let refunded = refunded_cents.min(paid_cents);
51 let tax = (i128::from(refunded) * i128::from(tax_cents.max(0)) / i128::from(paid_cents)) as i64;
52 let fee = (i128::from(refunded) * i128::from(fee_cents.max(0)) / i128::from(paid_cents)) as i64;
53 (refunded - tax - fee, tax, fee)
54}
55
56/// What a payment's ledger line says was paid with it besides what it
57/// credited: its tax, and a card fee only on payments from before the fee
58/// was folded into prices. Nothing when there was neither.
59pub(crate) fn paid_with(extras: Extras) -> String {
60 let money = |cents: i64| crate::features::cents(cents * 10_000);
61 match (extras.tax_cents > 0, extras.fee_cents > 0) {
62 (false, false) => String::new(),
63 (true, false) => format!(" (tax {} paid with it)", money(extras.tax_cents)),
64 (false, true) => format!(" (card fee {} paid with it)", money(extras.fee_cents)),
65 (true, true) => format!(" (tax {} and card fee {} paid with it)", money(extras.tax_cents), money(extras.fee_cents)),
66 }
67}
68
69/// What the owners are told when Stripe Tax cannot place the workspace.
70pub(crate) fn address_needed_message(workspace: &str) -> String {
71 format!(
72 "Add {workspace}'s billing address in Stripe: on the Billing page (/{workspace}/-/billing#stripe), choose Manage billing. Stripe needs it to work out tax, so g1t did not charge the card; nothing is lost, and the charge goes through once the address is there."
73 )
74}
75
76#[derive(Deserialize)]
77struct Sum {
78 micros: Option<f64>,
79}
80
81impl Billing {
82 /// Keeps a payment's tax and card fee apart from what it paid for, once
83 /// per payment and kind.
84 pub(crate) async fn record_extras(
85 &self,
86 workspace: &str,
87 reference: &str,
88 payment_intent: Option<&str>,
89 extras: Extras,
90 tax_transaction: Option<&str>,
91 ) -> Result<()> {
92 let now = rfc3339(now_ms());
93 let mut writes = vec![];
94 for (kind, cents) in [("tax", extras.tax_cents), ("card_fee", extras.fee_cents)] {
95 if cents == 0 {
96 continue;
97 }
98 writes.push(
99 self.db
100 .prepare(
101 "INSERT OR IGNORE INTO tax_and_fees (id, workspace, kind, amount_micros, reference, payment_intent, tax_transaction, created_at)
102 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
103 )
104 .bind(&[
105 format!("{reference}/{kind}").into(),
106 workspace.into(),
107 kind.into(),
108 ((cents * 10_000) as f64).into(),
109 reference.into(),
110 crate::optional(payment_intent),
111 crate::optional(if kind == "tax" { tax_transaction } else { None }),
112 now.as_str().into(),
113 ])?,
114 );
115 }
116 if !writes.is_empty() {
117 self.db.batch(writes).await?;
118 }
119 Ok(())
120 }
121
122 /// A payment's tax and fee, as kept, by the PaymentIntent that took it
123 /// or the invoice it paid; and its tax transaction, if any.
124 pub(crate) async fn extras_of(&self, payment_intent: Option<&str>, invoice: Option<&str>) -> Result<(Extras, Option<String>)> {
125 #[derive(Deserialize)]
126 struct Row {
127 kind: String,
128 amount_micros: i64,
129 tax_transaction: Option<String>,
130 }
131 let rows = self
132 .db
133 .prepare(
134 "SELECT kind, amount_micros, tax_transaction FROM tax_and_fees
135 WHERE ((payment_intent IS NOT NULL AND payment_intent = ?1) OR reference = ?2) AND reference NOT LIKE 'refund/%'",
136 )
137 .bind(&[payment_intent.unwrap_or("").into(), invoice.unwrap_or("").into()])?
138 .all()
139 .await?
140 .results::<Row>()?;
141 let mut extras = Extras::default();
142 let mut transaction = None;
143 for row in rows {
144 match row.kind.as_str() {
145 "tax" => {
146 extras.tax_cents += row.amount_micros / 10_000;
147 transaction = transaction.or(row.tax_transaction);
148 }
149 _ => extras.fee_cents += row.amount_micros / 10_000,
150 }
151 }
152 Ok((extras, transaction))
153 }
154
155 /// Marks that Stripe Tax could not place the workspace, and tells its
156 /// owners the first time.
157 pub(crate) async fn tax_address_needed(&self, workspace: &str) -> Result<()> {
158 let now = rfc3339(now_ms());
159 #[derive(Deserialize)]
160 struct Row {
161 #[allow(dead_code)]
162 workspace: String,
163 }
164 let first = self
165 .db
166 .prepare("UPDATE accounts SET tax_address_needed_at = ? WHERE workspace = ? AND tax_address_needed_at IS NULL RETURNING workspace")
167 .bind(&[now.as_str().into(), workspace.into()])?
168 .first::<Row>(None)
169 .await?
170 .is_some();
171 if first && let Some(identity) = &self.identity {
172 crate::limits::notify_with(
173 identity,
174 workspace,
175 &format!("g1t: add a billing address for {workspace}"),
176 &address_needed_message(workspace),
177 "Add the address",
178 &format!("https://g1t.sh/{workspace}/-/billing#stripe"),
179 "You get this because you own this workspace on g1t. Tax is explained at https://docs.g1t.sh/guides/usage-and-billing/#tax",
180 )
181 .await;
182 }
183 Ok(())
184 }
185
186 /// Clears the mark once an address is saved.
187 pub(crate) async fn tax_address_given(&self, workspace: &str) -> Result<()> {
188 self.db
189 .prepare("UPDATE accounts SET tax_address_needed_at = NULL WHERE workspace = ?")
190 .bind(&[workspace.into()])?
191 .run()
192 .await?;
193 Ok(())
194 }
195
196 /// When Stripe Tax last could not place the workspace, while it still
197 /// cannot.
198 pub(crate) async fn tax_address_needed_at(&self, workspace: &str) -> Result<Option<String>> {
199 #[derive(Deserialize)]
200 struct Row {
201 tax_address_needed_at: Option<String>,
202 }
203 Ok(self
204 .db
205 .prepare("SELECT tax_address_needed_at FROM accounts WHERE workspace = ?")
206 .bind(&[workspace.into()])?
207 .first::<Row>(None)
208 .await?
209 .and_then(|row| row.tax_address_needed_at))
210 }
211
212 /// Tax and card fees kept between two days (inclusive, `YYYY-MM-DD`),
213 /// in micros, for sudo: (tax, fees).
214 pub(crate) async fn extras_between(&self, from: &str, to: &str) -> Result<(i64, i64)> {
215 let sum = |kind: &'static str| async move {
216 Ok::<i64, worker::Error>(
217 self.db
218 .prepare("SELECT SUM(amount_micros) AS micros FROM tax_and_fees WHERE kind = ? AND substr(created_at, 1, 10) BETWEEN ? AND ?")
219 .bind(&[kind.into(), from.into(), to.into()])?
220 .first::<Sum>(None)
221 .await?
222 .and_then(|s| s.micros)
223 .unwrap_or(0.0) as i64,
224 )
225 };
226 Ok((sum("tax").await?, sum("card_fee").await?))
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn a_prepayment_credits_what_it_bought_never_its_tax_or_fee() {
236 // $50 bought, $1.85 card fee, $4 tax: $50 credited.
237 assert_eq!(prepay_credit_cents(5_185, 185), 5_000);
238 assert_eq!(prepay_credit_cents(100_000, 0), 100_000);
239 assert_eq!(prepay_credit_cents(100, 500), 0);
240 }
241
242 #[test]
243 fn a_refund_gives_back_tax_and_fee_in_proportion() {
244 // $54.00 paid: $50 credit, $1.85 fee, $2.15 tax; half refunded.
245 let (balance, tax, fee) = refund_split(2_700, 5_400, 215, 185);
246 assert_eq!(balance + tax + fee, 2_700);
247 assert_eq!((tax, fee), (107, 92));
248 // A payment with neither: all of it off the balance.
249 assert_eq!(refund_split(1_000, 1_000, 0, 0), (1_000, 0, 0));
250 // Never more than was paid.
251 assert_eq!(refund_split(9_000, 5_400, 215, 185).0, 5_000);
252 }
253
254 #[test]
255 fn a_payments_line_names_a_card_fee_only_when_there_was_one() {
256 assert_eq!(paid_with(Extras::default()), "");
257 assert_eq!(paid_with(Extras { tax_cents: 180, fee_cents: 0 }), " (tax $1.80 paid with it)");
258 assert_eq!(paid_with(Extras { tax_cents: 180, fee_cents: 92 }), " (tax $1.80 and card fee $0.92 paid with it)");
259 }
260
261 #[test]
262 fn the_owners_are_told_where_to_add_the_address() {
263 let message = address_needed_message("acme");
264 assert!(message.contains("/acme/-/billing#stripe") && message.contains("Manage billing"));
265 assert!(message.contains("tax"));
266 }
267}