Billing: Stripe Tax on every payment, the card fee on every card payment, and free_workspaces
Every Checkout page, subscription, invoice and auto-reload is taxed by Stripe Tax (txcd_10103001, prices exclusive), with the address and tax ID collected and saved on the customer. Tax and card fees are kept apart in tax_and_fees (migration 0041): never credited, never cash, their own statement lines, and Tax collected on sudo's Costs. Without an address nothing is charged and the owners are asked for one. The card fee is on by default on every card payment, never on bank transfers or enterprise invoices. free_workspaces tells identity which workspaces are on no paid plan. Enterprise billing address and tax ID from sudo.
19 files+1636−2120/19 viewed
| 1691 | 1691 | pub by: String, | |
| 1692 | 1692 | } | |
| 1693 | 1693 | ||
| 1694 | + | /// `admin_enterprise_address`: the enterprise's billing address and tax ID, | |
| 1695 | + | /// saved on its Stripe customer (made by `admin_enterprise_billing`). | |
| 1696 | + | /// Stripe Tax works its invoices' tax out from the address; an invoice is | |
| 1697 | + | /// not sent without one. Returns `Outcome<bool>`. | |
| 1698 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 1699 | + | #[serde(rename_all = "camelCase")] | |
| 1700 | + | pub struct AdminEnterpriseAddressArgs { | |
| 1701 | + | pub id: String, | |
| 1702 | + | pub address: PostalAddress, | |
| 1703 | + | #[serde(default, alias = "tax_id_type")] | |
| 1704 | + | pub tax_id_type: Option<String>, | |
| 1705 | + | #[serde(default, alias = "tax_id")] | |
| 1706 | + | pub tax_id: Option<String>, | |
| 1707 | + | pub by: String, | |
| 1708 | + | } | |
| 1709 | + | ||
| 1694 | 1710 | /// `admin_invoice_enterprise`: sends an enterprise its invoice now, for | |
| 1695 | 1711 | /// what its workspaces owe, rather than waiting for the month to close. | |
| 1696 | 1712 | /// Returns `Outcome<EnterpriseInvoice>`. | |
| ⋯ | |||
| 1740 | 1756 | pub pdf_url: Option<String>, | |
| 1741 | 1757 | pub lines: Vec<InvoiceItem>, | |
| 1742 | 1758 | pub created_at: String, | |
| 1759 | + | /// The card processing fee on top of `amount_micros`, when the invoice | |
| 1760 | + | /// is charged to a card; never part of the usage it pays for. | |
| 1761 | + | #[serde(default)] | |
| 1762 | + | pub fee_micros: i64, | |
| 1763 | + | /// The tax Stripe added on top, once it is known (after paying). | |
| 1764 | + | #[serde(default)] | |
| 1765 | + | pub tax_micros: i64, | |
| 1743 | 1766 | } | |
| 1744 | 1767 | ||
| 1745 | 1768 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| ⋯ | |||
| 1827 | 1850 | /// What the account's discount took off the line's price. | |
| 1828 | 1851 | #[serde(default)] | |
| 1829 | 1852 | pub discount_micros: i64, | |
| 1853 | + | /// On the `Tax` and `Card processing fees` lines: what was paid with | |
| 1854 | + | /// payments on top of what reached the balance (negative for what a | |
| 1855 | + | /// refund gave back). Never in `charged_micros` or the balance. | |
| 1856 | + | #[serde(default)] | |
| 1857 | + | pub passed_micros: i64, | |
| 1830 | 1858 | } | |
| 1831 | 1859 | ||
| 1832 | 1860 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| ⋯ | |||
| 1853 | 1881 | /// carries over to the next invoice. Zero when nothing carried. | |
| 1854 | 1882 | #[serde(default)] | |
| 1855 | 1883 | pub carried_micros: i64, | |
| 1884 | + | /// Tax and card processing fees paid with the month's payments, on top | |
| 1885 | + | /// of `paid_micros`. | |
| 1886 | + | #[serde(default)] | |
| 1887 | + | pub tax_micros: i64, | |
| 1888 | + | #[serde(default)] | |
| 1889 | + | pub card_fee_micros: i64, | |
| 1856 | 1890 | } | |
| 1857 | 1891 | ||
| 1858 | 1892 | /// One source that paid for usage before it was charged. | |
| ⋯ | |||
| 2458 | 2492 | pub struct Plan { | |
| 2459 | 2493 | pub feature: Feature, | |
| 2460 | 2494 | pub title: String, | |
| 2461 | − | /// Charged every month while the plan is on, in cents. | |
| 2495 | + | /// Charged every month while the plan is on, in cents, excluding tax. | |
| 2462 | 2496 | pub monthly_cents: u32, | |
| 2497 | + | /// The card processing fee on top each month, in cents (0 when the | |
| 2498 | + | /// fee is off). Excluding tax, like the price. | |
| 2499 | + | #[serde(default)] | |
| 2500 | + | pub card_fee_cents: u32, | |
| 2463 | 2501 | /// What the monthly price includes, one line each, for people to read. | |
| 2464 | 2502 | pub includes: Vec<String>, | |
| 2465 | 2503 | /// How usage past the allowance is charged, for people to read. | |
| ⋯ | |||
| 2598 | 2636 | pub feature: Feature, | |
| 2599 | 2637 | } | |
| 2600 | 2638 | ||
| 2639 | + | /// `free_workspaces`: which of `workspaces` are free, that is on no paid | |
| 2640 | + | /// plan. Paid is the g1t plan, an enterprise's terms, or a discount of | |
| 2641 | + | /// 100% (g1t's own workspaces). Identity asks before a workspace is made | |
| 2642 | + | /// (a person owns at most one free workspace) and before anyone is added | |
| 2643 | + | /// to one (a free workspace cannot invite). Returns `Vec<String>`, the | |
| 2644 | + | /// free ones, lower-cased; none where payments are not set up. | |
| 2645 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 2646 | + | pub struct FreeWorkspacesArgs { | |
| 2647 | + | pub workspaces: Vec<String>, | |
| 2648 | + | } | |
| 2649 | + | ||
| 2601 | 2650 | /// `charge_feature`: usage of a feature past its plan's allowance, charged | |
| 2602 | 2651 | /// from the workspace's credit at cost plus the margin, whatever | |
| 2603 | 2652 | /// `FREE_WHILE_BUILDING` says. Called by the service that provides it. | |
| ⋯ | |||
| 2746 | 2795 | pub cloudflare_cost_micros: i64, | |
| 2747 | 2796 | #[serde(default)] | |
| 2748 | 2797 | pub models_cost_micros: i64, | |
| 2798 | + | /// Tax collected with payments over the range, net of refunds: owed to | |
| 2799 | + | /// the tax authorities, never in cash or revenue. | |
| 2800 | + | #[serde(default)] | |
| 2801 | + | pub tax_collected_micros: i64, | |
| 2802 | + | /// Card processing fees passed on with card payments, net of refunds: | |
| 2803 | + | /// they pay Stripe's fee, so they are not revenue either. | |
| 2804 | + | #[serde(default)] | |
| 2805 | + | pub card_fees_micros: i64, | |
| 2749 | 2806 | } | |
| 2750 | 2807 | ||
| 2751 | 2808 | /// A count, cost or leak that does not add up. | |
| ⋯ | |||
| 2891 | 2948 | /// and at least `anomaly_floor_micros`, is flagged. | |
| 2892 | 2949 | pub anomaly_factor: f64, | |
| 2893 | 2950 | pub anomaly_floor_micros: i64, | |
| 2894 | − | /// Pass Stripe's card fee on as its own line when AI credit is bought | |
| 2895 | − | /// by card (`card_fee_percent` and `card_fee_fixed` in the price book). | |
| 2951 | + | /// Pass Stripe's card fee on as its own line on every card payment (the | |
| 2952 | + | /// plan, Security and quality, prepaying, AI credit, auto-reload and | |
| 2953 | + | /// invoices charged to a card), never on a bank transfer or an invoice | |
| 2954 | + | /// sent to be paid (`card_fee_percent` and `card_fee_fixed` in the | |
| 2955 | + | /// price book). On by default. | |
| 2896 | 2956 | #[serde(default = "yes")] | |
| 2897 | 2957 | pub card_fee: bool, | |
| 2898 | 2958 | } | |
| ⋯ | |||
| 3500 | 3560 | /// Stripe could not be read: what is shown is what g1t keeps. | |
| 3501 | 3561 | #[serde(default)] | |
| 3502 | 3562 | pub unavailable: Option<String>, | |
| 3563 | + | /// Whether Stripe Tax can place the customer from the address: tax | |
| 3564 | + | /// is worked out from it, and without it nothing is charged. | |
| 3565 | + | #[serde(default)] | |
| 3566 | + | pub tax_location: bool, | |
| 3567 | + | /// Set when g1t did not charge for want of an address (RFC 3339). | |
| 3568 | + | #[serde(default)] | |
| 3569 | + | pub tax_address_needed_at: Option<String>, | |
| 3570 | + | /// Stripe's check of the tax ID: `pending`, `verified`, `unverified` or | |
| 3571 | + | /// `unavailable`. | |
| 3572 | + | #[serde(default)] | |
| 3573 | + | pub tax_id_status: Option<String>, | |
| 3574 | + | /// `none`, `exempt` or `reverse`, as staff set it at Stripe; g1t never | |
| 3575 | + | /// changes it. | |
| 3576 | + | #[serde(default)] | |
| 3577 | + | pub tax_exempt: Option<String>, | |
| 3503 | 3578 | } | |
| 3504 | 3579 | ||
| 3505 | 3580 | /// `set_billing_details`: saves the invoice details on the Stripe customer. | |
| 457 | 457 | pdfUrl: string | null; | |
| 458 | 458 | lines: { description: string; amountMicros: number }[]; | |
| 459 | 459 | createdAt: string; | |
| 460 | + | /** The card processing fee on top of `amountMicros` when charged to a card, and the tax Stripe added once known. */ | |
| 461 | + | feeMicros?: number; | |
| 462 | + | taxMicros?: number; | |
| 460 | 463 | }; | |
| 461 | 464 | ||
| 462 | 465 | /** A month of the ledger, grouped by day or project, a line per kind of charge. */ | |
| ⋯ | |||
| 476 | 479 | coveredMicros?: number; | |
| 477 | 480 | priceMicros?: number; | |
| 478 | 481 | discountMicros?: number; | |
| 482 | + | /** On `Tax` and `Card processing fees` lines: what was paid with payments on top of what reached the balance. Never charged. */ | |
| 483 | + | passedMicros?: number; | |
| 479 | 484 | }[]; | |
| 480 | 485 | chargedMicros: number; | |
| 481 | 486 | priceMicros?: number; | |
| ⋯ | |||
| 495 | 500 | covered?: { source: "included" | "trial" | "oss_pool" | "given" | string; label: string; micros: number }[]; | |
| 496 | 501 | /** Owed when the month closed but under the minimum charge: on the next invoice. */ | |
| 497 | 502 | carriedMicros?: number; | |
| 503 | + | /** Tax and card processing fees paid with the month's payments, on top of `paidMicros`. */ | |
| 504 | + | taxMicros?: number; | |
| 505 | + | cardFeeMicros?: number; | |
| 498 | 506 | }; | |
| 499 | 507 | }; | |
| 500 | 508 | ||
| ⋯ | |||
| 627 | 635 | stripe(fix?: boolean, by?: string): Promise<StripeStatus>; | |
| 628 | 636 | /** Where an enterprise's invoices go; makes its Stripe customer. */ | |
| 629 | 637 | enterpriseBilling(id: string, email: string, by: string): Promise<Result<PayingAccount>>; | |
| 638 | + | /** The enterprise's billing address and tax ID, on its Stripe customer: Stripe Tax works its invoices out from them. */ | |
| 639 | + | enterpriseAddress(id: string, address: PostalAddress, taxIdType: string | null, taxId: string | null, by: string): Promise<Result<boolean>>; | |
| 630 | 640 | /** Sends an enterprise its invoice now, for what its workspaces owe. */ | |
| 631 | 641 | invoiceEnterprise(id: string, by: string): Promise<Result<EnterpriseInvoice>>; | |
| 632 | 642 | /** Exactly these workspaces' accounts, such as one page of the list. */ | |
| ⋯ | |||
| 851 | 861 | export type FeaturePlan = { | |
| 852 | 862 | feature: Feature; | |
| 853 | 863 | title: string; | |
| 854 | − | /** Charged every month while the plan is on, in cents. */ | |
| 864 | + | /** Charged every month while the plan is on, in cents, excluding tax. */ | |
| 855 | 865 | monthlyCents: number; | |
| 866 | + | /** The card processing fee on top each month, in cents (0 when off), excluding tax. */ | |
| 867 | + | cardFeeCents?: number; | |
| 856 | 868 | /** What the price includes, one line each. */ | |
| 857 | 869 | includes: string[]; | |
| 858 | 870 | /** How usage past the allowance is charged. */ | |
| ⋯ | |||
| 963 | 975 | confirmSubscription(workspace: string, viewer: Viewer, session: string): Promise<Result<FeatureState>>; | |
| 964 | 976 | /** Ends a plan at the end of its period, or (`resume`) takes that back. Owners only. */ | |
| 965 | 977 | cancelSubscription(actor: User, workspace: string, feature: Feature, resume?: boolean): Promise<Result<FeatureState>>; | |
| 978 | + | /** Which of the workspaces are free (on no paid plan); none where payments are not set up. */ | |
| 979 | + | freeWorkspaces(workspaces: string[]): Promise<string[]>; | |
| 966 | 980 | /** Whether a feature works for a workspace now; a failure with the reason when not. */ | |
| 967 | 981 | hasFeature(workspace: string, feature: Feature): Promise<Result<boolean>>; | |
| 968 | 982 | /** | |
| ⋯ | |||
| 1233 | 1247 | includedMicros?: number; | |
| 1234 | 1248 | cloudflareCostMicros?: number; | |
| 1235 | 1249 | modelsCostMicros?: number; | |
| 1250 | + | /** Tax collected with payments over the range, net of refunds: owed to tax authorities, never cash or revenue. */ | |
| 1251 | + | taxCollectedMicros?: number; | |
| 1252 | + | /** Card processing fees passed on with card payments, net of refunds: they pay Stripe's fee, not revenue. */ | |
| 1253 | + | cardFeesMicros?: number; | |
| 1236 | 1254 | }; | |
| 1237 | 1255 | ||
| 1238 | 1256 | /** A count, cost or leak that does not add up. */ | |
| ⋯ | |||
| 1562 | 1580 | invoices: StripeInvoice[]; | |
| 1563 | 1581 | upcoming: UpcomingInvoice; | |
| 1564 | 1582 | unavailable?: string | null; | |
| 1583 | + | /** Whether Stripe Tax can place the customer from the address; without it nothing is charged. */ | |
| 1584 | + | taxLocation?: boolean; | |
| 1585 | + | /** Set when g1t did not charge for want of a billing address. */ | |
| 1586 | + | taxAddressNeededAt?: string | null; | |
| 1587 | + | /** Stripe's check of the tax ID: pending, verified, unverified or unavailable. */ | |
| 1588 | + | taxIdStatus?: string | null; | |
| 1589 | + | /** none, exempt or reverse, as set at Stripe. */ | |
| 1590 | + | taxExempt?: string | null; | |
| 1565 | 1591 | }; | |
| 1566 | 1592 | ||
| 1567 | 1593 | export type BillingDetailsInput = { | |
| 470 | 470 | cancelSubscription: (actor, workspace, feature, resume = false) => | |
| 471 | 471 | call("cancel_subscription", { actor, workspace, feature, resume }), | |
| 472 | 472 | hasFeature: (workspace, feature) => call("has_feature", { workspace, feature }), | |
| 473 | + | freeWorkspaces: (workspaces) => call("free_workspaces", { workspaces }), | |
| 473 | 474 | chargeFeature: (charge) => call("charge_feature", charge), | |
| 474 | 475 | billingPortal: (actor, workspace, returnUrl) => call("billing_portal", { actor, workspace, return_url: returnUrl }), | |
| 475 | 476 | recordSandbox: (usage) => call("record_sandbox", usage), | |
| ⋯ | |||
| 534 | 535 | billingLink: (workspace, by) => call("admin_billing_link", { workspace, by }), | |
| 535 | 536 | stripe: (fix = false, by) => call("admin_stripe", { fix, by: by ?? null }), | |
| 536 | 537 | enterpriseBilling: (id, email, by) => call("admin_enterprise_billing", { id, email, by }), | |
| 538 | + | enterpriseAddress: (id, address, taxIdType, taxId, by) => call("admin_enterprise_address", { id, address, taxIdType, taxId, by }), | |
| 537 | 539 | invoiceEnterprise: (id, by) => call("admin_invoice_enterprise", { id, by }), | |
| 538 | 540 | accountsFor: (workspaces) => call("admin_accounts", { query: null, workspaces }), | |
| 539 | 541 | signals: () => call("admin_signals", {}), | |
| 1 | + | -- Stripe Tax on every payment, and the card processing fee on every card | |
| 2 | + | -- payment. | |
| 3 | + | -- | |
| 4 | + | -- Prices are shown and kept excluding tax. Stripe works the tax out on | |
| 5 | + | -- every Checkout page, subscription, invoice and off-session charge | |
| 6 | + | -- (`automatic_tax`, tax code `txcd_10103001`, tax behavior `exclusive`). | |
| 7 | + | -- What reaches a workspace's balance is the payment less its tax and its | |
| 8 | + | -- card fee; neither is revenue. Each is kept here, one row per payment and | |
| 9 | + | -- kind, so the statement shows them as their own lines and sudo's Costs | |
| 10 | + | -- shows tax collected apart from cash. | |
| 11 | + | ||
| 12 | + | CREATE TABLE IF NOT EXISTS tax_and_fees ( | |
| 13 | + | -- `<reference>/tax` or `<reference>/card_fee`. A refund's share is | |
| 14 | + | -- negative, under the refund's reference `refund/<charge>/<refunded>`. | |
| 15 | + | id TEXT PRIMARY KEY, | |
| 16 | + | -- The workspace, or for an enterprise's invoice its billing account. | |
| 17 | + | workspace TEXT NOT NULL, | |
| 18 | + | -- tax or card_fee. | |
| 19 | + | kind TEXT NOT NULL, | |
| 20 | + | -- Positive when collected, negative when refunded. | |
| 21 | + | amount_micros INTEGER NOT NULL, | |
| 22 | + | -- The payment: an invoice, a Checkout page or a PaymentIntent. | |
| 23 | + | reference TEXT NOT NULL, | |
| 24 | + | -- The PaymentIntent that took the money, so a refund finds its tax. | |
| 25 | + | payment_intent TEXT, | |
| 26 | + | -- Stripe Tax's transaction for an off-session charge (auto-reload), | |
| 27 | + | -- which a refund reverses. | |
| 28 | + | tax_transaction TEXT, | |
| 29 | + | created_at TEXT NOT NULL | |
| 30 | + | ); | |
| 31 | + | CREATE INDEX IF NOT EXISTS tax_and_fees_by_workspace ON tax_and_fees (workspace, created_at); | |
| 32 | + | CREATE INDEX IF NOT EXISTS tax_and_fees_by_day ON tax_and_fees (created_at); | |
| 33 | + | CREATE INDEX IF NOT EXISTS tax_and_fees_by_intent ON tax_and_fees (payment_intent); | |
| 34 | + | ||
| 35 | + | -- A workspace invoice's card fee and tax, apart from the usage it pays for. | |
| 36 | + | ALTER TABLE workspace_invoices ADD COLUMN fee_micros INTEGER NOT NULL DEFAULT 0; | |
| 37 | + | ALTER TABLE workspace_invoices ADD COLUMN tax_micros INTEGER NOT NULL DEFAULT 0; | |
| 38 | + | ||
| 39 | + | -- Set when Stripe Tax could not work out where the customer is (no | |
| 40 | + | -- billing address), so g1t did not charge: the Billing page asks an owner | |
| 41 | + | -- for the address, and saving it clears this. | |
| 42 | + | ALTER TABLE accounts ADD COLUMN tax_address_needed_at TEXT; | |
| 43 | + | ||
| 44 | + | -- The card processing fee is on for every card payment, not only AI | |
| 45 | + | -- credit: the setting's note says so. Never on invoiced or enterprise | |
| 46 | + | -- payments, or bank transfers. | |
| 47 | + | INSERT OR IGNORE INTO cost_settings (key, value, updated_at, updated_by) VALUES | |
| 48 | + | ('card_fee', 'on', '2026-10-08T00:00:00Z', 'migration'); | |
| 49 | + | ||
| 50 | + | INSERT OR IGNORE INTO price_changes (id, meter, old_cost_micros, new_cost_micros, markup_percent, reason, created_at) VALUES | |
| 51 | + | ('prc_card_fee_all_cards', 'card_fee_percent', 29000, 29000, 0, 'Stripe''s card fee (2.9% + $0.30) is passed on as its own line on every card payment: the plan, Security and quality, prepaying, AI credit and invoices charged to a card. None on bank transfers or invoiced (enterprise) billing. Prices exclude tax; tax is added where it applies', '2026-10-08T00:00:00Z'); |
| 462 | 462 | let fee = i64::from(open.fee_cents.unwrap_or(0)) * 10_000; | |
| 463 | 463 | self.grant_purchased(&open.workspace, session_id, micros, fee, &open.created_by, session.customer.as_deref()) | |
| 464 | 464 | .await?; | |
| 465 | + | let extras = crate::tax::Extras { tax_cents: session.tax_cents(), fee_cents: fee / 10_000 }; | |
| 466 | + | self.record_extras(&open.workspace, session_id, session.payment_intent.as_deref(), extras, None).await?; | |
| 465 | 467 | Ok(Ok(format!("{}: {} of AI credit bought", open.workspace, cents(micros)))) | |
| 466 | 468 | } | |
| 467 | 469 | ||
| ⋯ | |||
| 733 | 735 | ])? | |
| 734 | 736 | .run() | |
| 735 | 737 | .await?; | |
| 736 | − | let charge = crate::stripe::SavedCharge { | |
| 738 | + | let untaxed = crate::stripe::SavedCharge { | |
| 737 | 739 | workspace, | |
| 738 | 740 | customer: &customer, | |
| 739 | 741 | payment_method: &method.id, | |
| 740 | 742 | credit_cents, | |
| 741 | 743 | fee_cents, | |
| 744 | + | tax_cents: 0, | |
| 745 | + | tax_calculation: None, | |
| 742 | 746 | key: &key, | |
| 743 | 747 | }; | |
| 748 | + | // No Checkout page or invoice works the tax out here: Stripe Tax's | |
| 749 | + | // calculation does, for the customer's saved address. Without one, | |
| 750 | + | // nothing is charged and the owners are asked for it. | |
| 751 | + | let calculation = match stripe.tax_calculation(&untaxed).await { | |
| 752 | + | Ok(calculation) => calculation, | |
| 753 | + | Err(error) if crate::stripe::is_tax_location_error(&error) => { | |
| 754 | + | self.tax_address_needed(workspace).await?; | |
| 755 | + | self.reload_failed(workspace, Some(&key), amount, "Stripe needs the workspace's billing address to work out tax; add it under Invoice details") | |
| 756 | + | .await?; | |
| 757 | + | return Ok(None); | |
| 758 | + | } | |
| 759 | + | Err(error) => return Err(error), | |
| 760 | + | }; | |
| 761 | + | let tax_cents = u32::try_from(calculation.tax_amount_exclusive.max(0)).unwrap_or(0); | |
| 762 | + | let charge = crate::stripe::SavedCharge { tax_cents, tax_calculation: Some(&calculation.id), ..untaxed }; | |
| 744 | 763 | let paid = match stripe.charge_saved(&charge).await { | |
| 745 | 764 | Ok(intent) if intent["status"].as_str() == Some("succeeded") => intent["id"].as_str().map(str::to_owned), | |
| 746 | 765 | Ok(intent) => { | |
| ⋯ | |||
| 761 | 780 | .run() | |
| 762 | 781 | .await?; | |
| 763 | 782 | self.grant_purchased(workspace, &intent, amount, i64::from(fee_cents) * 10_000, "g1t", Some(&customer)).await?; | |
| 783 | + | // Recorded with Stripe Tax once paid, so it is reported and filed; | |
| 784 | + | // a failure is logged and the payment stands. | |
| 785 | + | let transaction = match stripe.record_tax(&calculation.id, &intent).await { | |
| 786 | + | Ok(id) => Some(id), | |
| 787 | + | Err(error) => { | |
| 788 | + | worker::console_error!("{workspace}: the tax on auto-reload {intent} was not recorded with Stripe Tax: {error}"); | |
| 789 | + | None | |
| 790 | + | } | |
| 791 | + | }; | |
| 792 | + | let extras = crate::tax::Extras { tax_cents: i64::from(tax_cents), fee_cents: i64::from(fee_cents) }; | |
| 793 | + | self.record_extras(workspace, &intent, Some(&intent), extras, transaction.as_deref()).await?; | |
| 794 | + | self.tax_address_given(workspace).await?; | |
| 764 | 795 | Ok(Some(amount)) | |
| 765 | 796 | } | |
| 766 | 797 | ||
| 123 | 123 | return Ok(Ok("ignored: settled meanwhile".to_owned())); | |
| 124 | 124 | } | |
| 125 | 125 | let workspace = open.workspace; | |
| 126 | + | // The address entered with the card, onto a customer that has none, | |
| 127 | + | // so the plan and invoices that follow can be taxed. | |
| 128 | + | if let (Some(customer), Some(address)) = (session.customer.as_deref(), card.address.as_ref()) { | |
| 129 | + | match stripe.fill_address(customer, address).await { | |
| 130 | + | Ok(true) => self.tax_address_given(&workspace).await?, | |
| 131 | + | Ok(false) => {} | |
| 132 | + | Err(error) => worker::console_error!("{workspace}: the card's billing address was not saved on the customer: {error}"), | |
| 133 | + | } | |
| 134 | + | } | |
| 126 | 135 | let now = rfc3339(now_ms()); | |
| 127 | 136 | #[derive(Deserialize)] | |
| 128 | 137 | struct Count { |
| 311 | 311 | Ok(self.plan_kind(workspace).await? != PlanKind::Free) | |
| 312 | 312 | } | |
| 313 | 313 | ||
| 314 | + | /// `free_workspaces`: those of the workspaces on no paid plan. With no | |
| 315 | + | /// card processor nothing is free (`plan_kind_for` says paid), so a | |
| 316 | + | /// g1t without payments limits no one. | |
| 317 | + | pub(crate) async fn free_workspaces(&self, a: g1t_contracts::billing::FreeWorkspacesArgs) -> Result<Vec<String>> { | |
| 318 | + | let mut free = vec![]; | |
| 319 | + | // A person belongs to a bounded number of workspaces; this caps the | |
| 320 | + | // reads all the same. | |
| 321 | + | for workspace in a.workspaces.iter().take(100) { | |
| 322 | + | let workspace = workspace.trim().to_lowercase(); | |
| 323 | + | if workspace.is_empty() || free.contains(&workspace) { | |
| 324 | + | continue; | |
| 325 | + | } | |
| 326 | + | if self.plan_kind(&workspace).await? == PlanKind::Free { | |
| 327 | + | free.push(workspace); | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | Ok(free) | |
| 331 | + | } | |
| 332 | + | ||
| 314 | 333 | /// What one monthly allowance has used. | |
| 315 | 334 | pub(crate) async fn allowance_used(&self, kind: &str, scope: &str, month: &str) -> Result<i64> { | |
| 316 | 335 | Ok(self |
| 35 | 35 | if a.name.as_deref().is_some_and(|n| n.trim().chars().count() > 200) { | |
| 36 | 36 | return Some("Keep the company name under 200 characters."); | |
| 37 | 37 | } | |
| 38 | − | if let Some(address) = &a.address { | |
| 39 | − | if !address.country.trim().is_empty() && (address.country.trim().len() != 2 || !address.country.trim().chars().all(|c| c.is_ascii_alphabetic())) { | |
| 40 | − | return Some("The country is two letters, such as US or DE."); | |
| 41 | − | } | |
| 42 | − | let parts = [&address.line1, &address.line2, &address.city, &address.state, &address.postal_code]; | |
| 43 | − | if parts.iter().any(|p| p.chars().count() > 200) { | |
| 44 | − | return Some("Keep each line of the address under 200 characters."); | |
| 45 | − | } | |
| 38 | + | if let Some(why) = a.address.as_ref().and_then(address_invalid) { | |
| 39 | + | return Some(why); | |
| 46 | 40 | } | |
| 47 | 41 | if a.po_number.as_deref().is_some_and(|p| p.trim().chars().count() > 140) { | |
| 48 | 42 | return Some("Keep the purchase order under 140 characters."); | |
| ⋯ | |||
| 52 | 46 | { | |
| 53 | 47 | return Some("Stripe does not write invoices in that language."); | |
| 54 | 48 | } | |
| 55 | − | match (a.tax_id_type.as_deref().map(str::trim), a.tax_id.as_deref().map(str::trim)) { | |
| 49 | + | tax_id_invalid(a.tax_id_type.as_deref(), a.tax_id.as_deref()) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// Puts a tax ID on the customer in place of the one there was; an empty | |
| 53 | + | /// value only takes the old one off. Stripe checks it (EU VAT numbers | |
| 54 | + | /// against VIES, for one) and Stripe Tax uses it, such as for a reverse | |
| 55 | + | /// charge. Why not, as a sentence, when Stripe refuses it. | |
| 56 | + | pub(crate) async fn replace_tax_id( | |
| 57 | + | stripe: &crate::stripe::Stripe, | |
| 58 | + | customer: &str, | |
| 59 | + | owner: &str, | |
| 60 | + | kind: &str, | |
| 61 | + | value: &str, | |
| 62 | + | ) -> std::result::Result<(), String> { | |
| 63 | + | let (kind, value) = (kind.trim(), value.trim()); | |
| 64 | + | let existing: Result<Value> = stripe.get(&format!("/customers/{customer}/tax_ids?limit=10")).await; | |
| 65 | + | let existing = existing.ok().and_then(|list| list["data"].as_array().cloned()).unwrap_or_default(); | |
| 66 | + | if existing.iter().any(|t| t["type"].as_str() == Some(kind) && t["value"].as_str() == Some(value)) { | |
| 67 | + | return Ok(()); | |
| 68 | + | } | |
| 69 | + | if !value.is_empty() { | |
| 70 | + | let key = format!("tax_id/{owner}/{kind}/{value}"); | |
| 71 | + | let added: Result<Value> = | |
| 72 | + | stripe.post_idempotent(&format!("/customers/{customer}/tax_ids"), &[("type", kind.to_owned()), ("value", value.to_owned())], &key).await; | |
| 73 | + | if let Err(error) = added { | |
| 74 | + | return Err(crate::stripe::friendly(&error)); | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | for old in existing { | |
| 78 | + | if let Some(id) = old["id"].as_str() { | |
| 79 | + | let _: Result<Value> = stripe.delete(&format!("/customers/{customer}/tax_ids/{id}")).await; | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | Ok(()) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// What is wrong with an address, if anything. | |
| 86 | + | pub(crate) fn address_invalid(address: &PostalAddress) -> Option<&'static str> { | |
| 87 | + | if !address.country.trim().is_empty() && (address.country.trim().len() != 2 || !address.country.trim().chars().all(|c| c.is_ascii_alphabetic())) { | |
| 88 | + | return Some("The country is two letters, such as US or DE."); | |
| 89 | + | } | |
| 90 | + | let parts = [&address.line1, &address.line2, &address.city, &address.state, &address.postal_code]; | |
| 91 | + | if parts.iter().any(|p| p.chars().count() > 200) { | |
| 92 | + | return Some("Keep each line of the address under 200 characters."); | |
| 93 | + | } | |
| 94 | + | None | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// What is wrong with a tax ID, if anything: a kind Stripe takes, and a | |
| 98 | + | /// number of letters, digits and separators. | |
| 99 | + | pub(crate) fn tax_id_invalid(kind: Option<&str>, value: Option<&str>) -> Option<&'static str> { | |
| 100 | + | match (kind.map(str::trim), value.map(str::trim)) { | |
| 56 | 101 | (Some(kind), Some(value)) if !kind.is_empty() && !value.is_empty() => { | |
| 57 | − | if kind.len() > 20 || !kind.chars().all(|c| c.is_ascii_lowercase() || c == '_') { | |
| 102 | + | if !TAX_ID_TYPES.contains(&kind) { | |
| 58 | 103 | return Some("Choose the kind of tax ID from the list."); | |
| 59 | 104 | } | |
| 60 | − | if value.chars().count() > 60 { | |
| 61 | − | return Some("That tax ID is too long."); | |
| 105 | + | if value.chars().count() > 60 || !value.chars().all(|c| c.is_ascii_alphanumeric() || " .-/".contains(c)) { | |
| 106 | + | return Some("That is not a tax ID: letters, digits, spaces, dots, hyphens and slashes, up to 60."); | |
| 62 | 107 | } | |
| 108 | + | None | |
| 63 | 109 | } | |
| 64 | − | (Some(kind), Some(value)) if kind.is_empty() != value.is_empty() => return Some("Give the tax ID's kind and its number together."), | |
| 65 | − | _ => {} | |
| 110 | + | (Some(kind), Some(value)) if kind.is_empty() != value.is_empty() => Some("Give the tax ID's kind and its number together."), | |
| 111 | + | _ => None, | |
| 66 | 112 | } | |
| 67 | − | None | |
| 68 | 113 | } | |
| 69 | 114 | ||
| 70 | 115 | /// The customer's fields to send for the details given: absent ones left | |
| ⋯ | |||
| 145 | 190 | }), | |
| 146 | 191 | tax_id_type: tax.and_then(|t| text(&t["type"])), | |
| 147 | 192 | tax_id: tax.and_then(|t| text(&t["value"])), | |
| 193 | + | tax_id_status: tax.and_then(|t| text(&t["verification"]["status"])), | |
| 194 | + | tax_exempt: text(&customer["tax_exempt"]), | |
| 195 | + | tax_location: crate::stripe::address_places_customer(address) | |
| 196 | + | || crate::stripe::address_places_customer(&customer["shipping"]["address"]), | |
| 148 | 197 | po_number: text(&customer["metadata"]["po_number"]), | |
| 149 | 198 | language: customer["preferred_locales"].as_array().and_then(|l| l.first()).and_then(text), | |
| 150 | 199 | ..BillingDetails::default() | |
| 151 | 200 | } | |
| 152 | 201 | } | |
| 153 | 202 | ||
| 203 | + | /// The kinds of tax ID Stripe takes on a customer (`tax_ids[type]`) in | |
| 204 | + | /// the API version billing is written for. | |
| 205 | + | pub(crate) const TAX_ID_TYPES: &[&str] = &[ | |
| 206 | + | "ad_nrt", "ae_trn", "al_tin", "am_tin", "ao_tin", "ar_cuit", "au_abn", "au_arn", "ba_tin", "bb_tin", "bg_uic", "bh_vat", | |
| 207 | + | "bo_tin", "br_cnpj", "br_cpf", "bs_tin", "by_tin", "ca_bn", "ca_gst_hst", "ca_pst_bc", "ca_pst_mb", "ca_pst_sk", "ca_qst", | |
| 208 | + | "cd_nif", "ch_uid", "ch_vat", "cl_tin", "cn_tin", "co_nit", "cr_tin", "de_stn", "do_rcn", "ec_ruc", "eg_tin", "es_cif", | |
| 209 | + | "eu_oss_vat", "eu_vat", "gb_vat", "ge_vat", "gn_nif", "hk_br", "hr_oib", "hu_tin", "id_npwp", "il_vat", "in_gst", "is_vat", | |
| 210 | + | "jp_cn", "jp_rn", "jp_trn", "ke_pin", "kh_tin", "kr_brn", "kz_bin", "li_uid", "li_vat", "ma_vat", "md_vat", "me_pib", | |
| 211 | + | "mk_vat", "mr_nif", "mx_rfc", "my_frp", "my_itn", "my_sst", "ng_tin", "no_vat", "no_voec", "np_pan", "nz_gst", "om_vat", | |
| 212 | + | "pe_ruc", "ph_tin", "ro_tin", "rs_pib", "ru_inn", "ru_kpp", "sa_vat", "sg_gst", "sg_uen", "si_tin", "sn_ninea", "sr_fin", | |
| 213 | + | "sv_nit", "th_vat", "tj_tin", "tr_tin", "tw_vat", "tz_vat", "ua_vat", "ug_tin", "us_ein", "uy_ruc", "uz_tin", "uz_vat", | |
| 214 | + | "ve_rif", "vn_tin", "za_vat", "zm_tin", "zw_tin", | |
| 215 | + | ]; | |
| 216 | + | ||
| 154 | 217 | impl Billing { | |
| 155 | 218 | /// `billing_details`: members only. | |
| 156 | 219 | pub(crate) async fn billing_details(&self, a: AccountArgs) -> Result<Outcome<BillingDetails>> { | |
| ⋯ | |||
| 179 | 242 | }); | |
| 180 | 243 | details.invoices = invoices.iter().filter_map(invoice_from).collect(); | |
| 181 | 244 | details.upcoming = upcoming; | |
| 245 | + | details.tax_address_needed_at = self.tax_address_needed_at(workspace).await?; | |
| 182 | 246 | Ok(details) | |
| 183 | 247 | } | |
| 184 | 248 | Err(error) => { | |
| ⋯ | |||
| 209 | 273 | let mut subscriptions = 0i64; | |
| 210 | 274 | for feature in [Feature::Plan, Feature::Security] { | |
| 211 | 275 | if self.plan_on(workspace, feature).await? { | |
| 212 | − | subscriptions += i64::from(self.plan(feature).await?.monthly_cents) * 10_000; | |
| 276 | + | let plan = self.plan(feature).await?; | |
| 277 | + | subscriptions += i64::from(plan.monthly_cents + plan.card_fee_cents) * 10_000; | |
| 213 | 278 | } | |
| 214 | 279 | } | |
| 215 | 280 | let terms = self.terms_of(workspace).await?; | |
| ⋯ | |||
| 263 | 328 | } | |
| 264 | 329 | } | |
| 265 | 330 | // A tax ID replaces the one there was. | |
| 266 | − | if let (Some(kind), Some(value)) = (a.tax_id_type.as_deref().map(str::trim), a.tax_id.as_deref().map(str::trim)) { | |
| 267 | − | let existing: Result<Value> = stripe.get(&format!("/customers/{customer}/tax_ids?limit=10")).await; | |
| 268 | − | let existing = existing.ok().and_then(|list| list["data"].as_array().cloned()).unwrap_or_default(); | |
| 269 | − | let same = existing.iter().any(|t| t["type"].as_str() == Some(kind) && t["value"].as_str() == Some(value)); | |
| 270 | − | if !same { | |
| 271 | − | if !value.is_empty() { | |
| 272 | − | let key = format!("tax_id/{workspace}/{kind}/{value}"); | |
| 273 | − | let added: Result<Value> = | |
| 274 | − | stripe.post_idempotent(&format!("/customers/{customer}/tax_ids"), &[("type", kind.to_owned()), ("value", value.to_owned())], &key).await; | |
| 275 | − | if let Err(error) = added { | |
| 276 | − | return Ok(Outcome::fail(FailureCode::Invalid, crate::stripe::friendly(&error))); | |
| 277 | − | } | |
| 278 | − | } | |
| 279 | − | for old in existing { | |
| 280 | − | if let Some(id) = old["id"].as_str() { | |
| 281 | − | let _: Result<Value> = stripe.delete(&format!("/customers/{customer}/tax_ids/{id}")).await; | |
| 282 | − | } | |
| 283 | − | } | |
| 284 | − | } | |
| 331 | + | if let (Some(kind), Some(value)) = (a.tax_id_type.as_deref(), a.tax_id.as_deref()) | |
| 332 | + | && let Err(why) = replace_tax_id(stripe, &customer, &workspace, kind, value).await | |
| 333 | + | { | |
| 334 | + | return Ok(Outcome::fail(FailureCode::Invalid, why)); | |
| 285 | 335 | } | |
| 286 | 336 | let account = self.account_of(&workspace).await?; | |
| 287 | 337 | self.audit(&account.id, "billing_details", &format!("{workspace}: invoice details changed"), &a.actor.username).await?; | |
| 288 | − | Ok(Outcome::Ok(self.details_of(&workspace).await?)) | |
| 338 | + | let details = self.details_of(&workspace).await?; | |
| 339 | + | // An address Stripe Tax can use lifts the hold on charging. | |
| 340 | + | if details.tax_location { | |
| 341 | + | self.tax_address_given(&workspace).await?; | |
| 342 | + | } | |
| 343 | + | Ok(Outcome::Ok(BillingDetails { tax_address_needed_at: None, ..details })) | |
| 289 | 344 | } | |
| 290 | 345 | } | |
| 291 | 346 | ||
| 129 | 129 | feature: Feature::Security, | |
| 130 | 130 | title: Feature::Security.title().to_owned(), | |
| 131 | 131 | monthly_cents: (micros / 10_000.0).round().max(0.0) as u32, | |
| 132 | + | card_fee_cents: 0, | |
| 132 | 133 | includes: vec![ | |
| 133 | 134 | "For every private repository in the workspace; public repositories have it free".to_owned(), | |
| 134 | 135 | "Custom secret patterns, validity checks with issuers, and delegated push protection bypass".to_owned(), | |
| ⋯ | |||
| 155 | 156 | impl Billing { | |
| 156 | 157 | /// What the g1t plan costs and includes, as it is sold now, at the | |
| 157 | 158 | /// price book's prices (the same figures as the pricing page's table). | |
| 159 | + | /// With the card fee on top of its monthly price, as it is charged. | |
| 158 | 160 | pub(crate) async fn plan(&self, feature: Feature) -> Result<Plan> { | |
| 159 | 161 | let mut book = std::collections::BTreeMap::new(); | |
| 160 | − | if feature == Feature::Security { | |
| 162 | + | let mut plan = if feature == Feature::Security { | |
| 161 | 163 | if let Some((_, price)) = self.price(SECURITY_METER).await? { | |
| 162 | 164 | book.insert(SECURITY_METER, price); | |
| 163 | 165 | } | |
| 164 | − | return Ok(security_plan_at(&book)); | |
| 165 | − | } | |
| 166 | − | for meter in ["build_second", "app_requests", "app_cpu", "custom_domain_month", "private_storage", "git_operations"] { | |
| 167 | − | if let Some((_, price)) = self.price(meter).await? { | |
| 168 | − | book.insert(meter, price); | |
| 166 | + | security_plan_at(&book) | |
| 167 | + | } else { | |
| 168 | + | for meter in ["build_second", "app_requests", "app_cpu", "custom_domain_month", "private_storage", "git_operations"] { | |
| 169 | + | if let Some((_, price)) = self.price(meter).await? { | |
| 170 | + | book.insert(meter, price); | |
| 171 | + | } | |
| 169 | 172 | } | |
| 170 | − | } | |
| 171 | − | Ok(self.plan_at(&book)) | |
| 173 | + | self.plan_at(&book) | |
| 174 | + | }; | |
| 175 | + | plan.card_fee_cents = self.card_fee_on(i64::from(plan.monthly_cents)).await? as u32; | |
| 176 | + | Ok(plan) | |
| 172 | 177 | } | |
| 173 | 178 | ||
| 174 | 179 | /// The plan at the given prices per unit (micros, after the markup); | |
| ⋯ | |||
| 182 | 187 | feature: Feature::Plan, | |
| 183 | 188 | title: Feature::Plan.title().to_owned(), | |
| 184 | 189 | monthly_cents: p.plan_monthly_cents, | |
| 190 | + | card_fee_cents: 0, | |
| 185 | 191 | includes: vec![ | |
| 186 | 192 | format!( | |
| 187 | 193 | "{} of usage each month at cost plus {}%, used first", | |
| ⋯ | |||
| 470 | 476 | }; | |
| 471 | 477 | if let (Some(customer), Some(method)) = (customer.as_deref(), saved) { | |
| 472 | 478 | match stripe | |
| 473 | − | .subscribe_with_card(&workspace, feature.as_str(), &plan.title, plan.monthly_cents, customer, &method) | |
| 479 | + | .subscribe_with_card(&workspace, feature.as_str(), &plan.title, plan.monthly_cents, plan.card_fee_cents, customer, &method) | |
| 474 | 480 | .await | |
| 475 | 481 | { | |
| 476 | 482 | Ok(subscription) if matches!(subscription.status.as_str(), "active" | "trialing") => { | |
| ⋯ | |||
| 504 | 510 | feature.as_str(), | |
| 505 | 511 | &plan.title, | |
| 506 | 512 | plan.monthly_cents, | |
| 513 | + | plan.card_fee_cents, | |
| 507 | 514 | customer.as_deref(), | |
| 508 | 515 | return_url, | |
| 509 | 516 | ) | |
| ⋯ | |||
| 533 | 540 | id: &session.id, | |
| 534 | 541 | workspace: &workspace, | |
| 535 | 542 | amount_cents: plan.monthly_cents, | |
| 536 | − | fee_cents: 0, | |
| 543 | + | fee_cents: plan.card_fee_cents, | |
| 537 | 544 | created_by: &a.actor.username, | |
| 538 | 545 | feature: Some(feature.as_str()), | |
| 539 | 546 | }) | |
| 66 | 66 | error.contains("answered 402") || error.contains("\"card_error\"") | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 69 | − | /// A workspace invoice's draft: charged to the card, and holding only the | |
| 70 | − | /// lines put on it, never whatever is pending on the customer. | |
| 69 | + | /// A workspace invoice's draft: charged to the card, taxed by Stripe Tax, | |
| 70 | + | /// and holding only the lines put on it, never whatever is pending on the | |
| 71 | + | /// customer. | |
| 71 | 72 | fn draft_fields(customer: &str, workspace: &str, reason: &str, period: &str, description: String) -> Vec<(&'static str, String)> { | |
| 72 | − | vec![ | |
| 73 | + | let mut fields = vec![ | |
| 73 | 74 | ("customer", customer.to_owned()), | |
| 74 | 75 | ("collection_method", "charge_automatically".to_owned()), | |
| 75 | 76 | ("auto_advance", "false".to_owned()), | |
| ⋯ | |||
| 78 | 79 | ("metadata[g1t_workspace]", workspace.to_owned()), | |
| 79 | 80 | ("metadata[reason]", reason.to_owned()), | |
| 80 | 81 | ("metadata[period]", period.to_owned()), | |
| 81 | − | ] | |
| 82 | + | ]; | |
| 83 | + | fields.extend(crate::stripe::invoice_tax_fields()); | |
| 84 | + | fields | |
| 82 | 85 | } | |
| 83 | 86 | ||
| 84 | − | /// One line, on the draft `invoice`. | |
| 87 | + | /// One line, on the draft `invoice`, at g1t's tax code, excluding tax. | |
| 85 | 88 | fn item_fields(customer: &str, invoice: &str, workspace: &str, description: &str, cents: i64) -> Vec<(&'static str, String)> { | |
| 86 | − | vec![ | |
| 89 | + | let mut fields = vec![ | |
| 87 | 90 | ("customer", customer.to_owned()), | |
| 88 | 91 | ("invoice", invoice.to_owned()), | |
| 89 | 92 | ("amount", cents.to_string()), | |
| 90 | 93 | ("currency", "usd".to_owned()), | |
| 91 | 94 | ("description", description.to_owned()), | |
| 92 | 95 | ("metadata[workspace]", workspace.to_owned()), | |
| 93 | − | ] | |
| 96 | + | ]; | |
| 97 | + | fields.extend(crate::stripe::item_tax_fields()); | |
| 98 | + | fields | |
| 94 | 99 | } | |
| 95 | 100 | ||
| 101 | + | /// Whether Stripe left an invoice a draft because Stripe Tax could not | |
| 102 | + | /// place the customer. | |
| 103 | + | pub(crate) fn needs_tax_location(invoice: &Value) -> bool { | |
| 104 | + | invoice["automatic_tax"]["status"].as_str() == Some("requires_location_inputs") | |
| 105 | + | } | |
| 106 | + | ||
| 96 | 107 | #[derive(Deserialize)] | |
| 97 | 108 | struct InvoiceRow { | |
| 98 | 109 | invoice_id: String, | |
| ⋯ | |||
| 104 | 115 | hosted_url: Option<String>, | |
| 105 | 116 | pdf_url: Option<String>, | |
| 106 | 117 | created_at: String, | |
| 118 | + | #[serde(default)] | |
| 119 | + | fee_micros: i64, | |
| 120 | + | #[serde(default)] | |
| 121 | + | tax_micros: i64, | |
| 107 | 122 | } | |
| 108 | 123 | ||
| 109 | 124 | #[derive(Deserialize)] | |
| ⋯ | |||
| 126 | 141 | amount_paid: i64, | |
| 127 | 142 | #[serde(default)] | |
| 128 | 143 | charge: Option<String>, | |
| 144 | + | #[serde(default)] | |
| 145 | + | payment_intent: Option<String>, | |
| 146 | + | /// The tax Stripe added, in cents (`tax`, in this API version). | |
| 147 | + | #[serde(default)] | |
| 148 | + | tax: Option<i64>, | |
| 129 | 149 | } | |
| 130 | 150 | ||
| 151 | + | impl StripeInvoice { | |
| 152 | + | fn tax_cents(&self) -> i64 { | |
| 153 | + | self.tax.unwrap_or(0).max(0) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 131 | 157 | impl Billing { | |
| 132 | 158 | /// Invoices the workspace for what it owes, charging its card. `Ok(Err)` | |
| 133 | 159 | /// says why not, when there was nothing to do or no card. | |
| ⋯ | |||
| 199 | 225 | .map(|u| (u.kind, u.charged.unwrap_or(0))) | |
| 200 | 226 | .collect(); | |
| 201 | 227 | let lines = invoice_lines(&used, owed); | |
| 202 | − | let key = format!("ws-invoice/{workspace}/{reason}/{period}/{}", owed / 10_000); | |
| 228 | + | // Stripe Tax needs to know where the customer is. Without an | |
| 229 | + | // address nothing is charged: the owners are asked for one, and the | |
| 230 | + | // charge goes through once it is there. | |
| 231 | + | match stripe.customer_placed(&customer).await { | |
| 232 | + | Ok(true) => {} | |
| 233 | + | Ok(false) => { | |
| 234 | + | self.tax_address_needed(workspace).await?; | |
| 235 | + | return Ok(Err(crate::tax::address_needed_message(workspace))); | |
| 236 | + | } | |
| 237 | + | Err(error) => return Ok(Err(crate::stripe::friendly(&error))), | |
| 238 | + | } | |
| 239 | + | let cents = line_cents(&lines); | |
| 240 | + | // Charged to a card: Stripe's fee is its own line. A bank account | |
| 241 | + | // set as the way to pay has no card fee. | |
| 242 | + | let by_card = match stripe.default_payment_method(&customer).await { | |
| 243 | + | Ok(method) => method.is_none_or(|m| m.kind == "card"), | |
| 244 | + | Err(_) => true, | |
| 245 | + | }; | |
| 246 | + | let fee_cents = if by_card { self.card_fee_on(cents.iter().sum::<i64>()).await? } else { 0 }; | |
| 247 | + | let key = format!("ws-invoice/{workspace}/{reason}/{period}/{}/{fee_cents}", owed / 10_000); | |
| 203 | 248 | let description = match reason { | |
| 204 | 249 | "month" => format!("g1t usage for {workspace}, {period}"), | |
| 205 | 250 | _ => format!("g1t usage for {workspace}, charged as it neared its limit"), | |
| ⋯ | |||
| 210 | 255 | // total, or the plan's renewal) on top of their own new lines. | |
| 211 | 256 | let draft: StripeInvoice = | |
| 212 | 257 | stripe.post_idempotent("/invoices", &draft_fields(&customer, workspace, reason, period, description), &key).await?; | |
| 213 | − | let cents = line_cents(&lines); | |
| 214 | 258 | for (position, (line, cents)) in lines.iter().zip(¢s).enumerate() { | |
| 215 | 259 | let fields = item_fields(&customer, &draft.id, workspace, &line.description, *cents); | |
| 216 | 260 | let _: Value = stripe.post_idempotent("/invoiceitems", &fields, &format!("{key}/item/{position}")).await?; | |
| 217 | 261 | } | |
| 262 | + | if fee_cents > 0 { | |
| 263 | + | let fields = item_fields(&customer, &draft.id, workspace, crate::stripe::CARD_FEE_LINE, fee_cents); | |
| 264 | + | let _: Value = stripe.post_idempotent("/invoiceitems", &fields, &format!("{key}/item/card_fee")).await?; | |
| 265 | + | } | |
| 218 | 266 | // A retry finds it finalized already; that is fine. | |
| 219 | − | let _ = stripe.post::<Value>(&format!("/invoices/{}/finalize", draft.id), &[]).await; | |
| 267 | + | let finalized = stripe.post::<Value>(&format!("/invoices/{}/finalize", draft.id), &[]).await; | |
| 268 | + | if let Err(error) = &finalized | |
| 269 | + | && crate::stripe::is_tax_location_error(error) | |
| 270 | + | { | |
| 271 | + | self.tax_address_needed(workspace).await?; | |
| 272 | + | return Ok(Err(crate::tax::address_needed_message(workspace))); | |
| 273 | + | } | |
| 274 | + | if let Ok(left) = stripe.get::<Value>(&format!("/invoices/{}", draft.id)).await | |
| 275 | + | && left["status"].as_str() == Some("draft") | |
| 276 | + | && needs_tax_location(&left) | |
| 277 | + | { | |
| 278 | + | self.tax_address_needed(workspace).await?; | |
| 279 | + | return Ok(Err(crate::tax::address_needed_message(workspace))); | |
| 280 | + | } | |
| 281 | + | self.tax_address_given(workspace).await?; | |
| 220 | 282 | // Charge the card now; a decline comes back as an error. | |
| 221 | 283 | let paid = stripe.post::<StripeInvoice>(&format!("/invoices/{}/pay", draft.id), &[("off_session", "true".to_owned())]).await; | |
| 222 | 284 | let invoice: StripeInvoice = stripe.get(&format!("/invoices/{}", draft.id)).await?; | |
| 223 | 285 | let total = lines.iter().map(|l| l.amount_micros).sum::<i64>(); | |
| 286 | + | let fee_micros = fee_cents * 10_000; | |
| 287 | + | let tax_micros = invoice.tax_cents() * 10_000; | |
| 224 | 288 | let status = if invoice.status.as_deref() == Some("paid") { "paid" } else { "failed" }; | |
| 225 | 289 | // Only the card saying no is a decline, which stops work until it | |
| 226 | 290 | // is paid. Stripe failing to answer, or answering busy, is g1t's | |
| ⋯ | |||
| 233 | 297 | .db | |
| 234 | 298 | .prepare( | |
| 235 | 299 | "INSERT OR REPLACE INTO workspace_invoices | |
| 236 | − | (invoice_id, workspace, reason, period, amount_micros, status, hosted_url, pdf_url, through_at, created_at, paid_at) | |
| 237 | − | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 300 | + | (invoice_id, workspace, reason, period, amount_micros, status, hosted_url, pdf_url, through_at, created_at, paid_at, | |
| 301 | + | fee_micros, tax_micros) | |
| 302 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 238 | 303 | ) | |
| 239 | 304 | .bind(&[ | |
| 240 | 305 | invoice.id.as_str().into(), | |
| ⋯ | |||
| 248 | 313 | now.as_str().into(), | |
| 249 | 314 | now.as_str().into(), | |
| 250 | 315 | crate::optional((status == "paid").then_some(now.as_str())), | |
| 316 | + | (fee_micros as f64).into(), | |
| 317 | + | (tax_micros as f64).into(), | |
| 251 | 318 | ])?]; | |
| 252 | 319 | for (position, line) in lines.iter().enumerate() { | |
| 253 | 320 | writes.push( | |
| ⋯ | |||
| 258 | 325 | } | |
| 259 | 326 | self.db.batch(writes).await?; | |
| 260 | 327 | if status == "paid" { | |
| 261 | − | self.credit_invoice(workspace, &invoice).await?; | |
| 328 | + | self.credit_invoice(workspace, &invoice, fee_cents).await?; | |
| 262 | 329 | } else { | |
| 263 | 330 | let error = paid.err().map_or_else(|| "the card was declined".to_owned(), |e| e.to_string().chars().take(200).collect()); | |
| 264 | 331 | self.mark_declined(workspace, &error).await?; | |
| ⋯ | |||
| 274 | 341 | pdf_url: invoice.invoice_pdf, | |
| 275 | 342 | lines, | |
| 276 | 343 | created_at: now, | |
| 344 | + | fee_micros, | |
| 345 | + | tax_micros, | |
| 277 | 346 | })) | |
| 278 | 347 | } | |
| 279 | 348 | ||
| 280 | − | /// Enters an invoice's payment once, with the kind of card that paid. | |
| 281 | − | async fn credit_invoice(&self, workspace: &str, invoice: &StripeInvoice) -> Result<bool> { | |
| 349 | + | /// Enters an invoice's payment once, with the kind of card that paid: | |
| 350 | + | /// what it paid for usage, never its tax or card fee, which are kept | |
| 351 | + | /// apart (`tax_and_fees`). | |
| 352 | + | async fn credit_invoice(&self, workspace: &str, invoice: &StripeInvoice, fee_cents: i64) -> Result<bool> { | |
| 282 | 353 | let seen = self | |
| 283 | 354 | .db | |
| 284 | 355 | .prepare("SELECT id FROM ledger WHERE reference = ?") | |
| ⋯ | |||
| 288 | 359 | if seen.is_some() { | |
| 289 | 360 | return Ok(false); | |
| 290 | 361 | } | |
| 291 | − | let amount = invoice.amount_paid * 10_000; | |
| 362 | + | let extras = crate::tax::Extras { tax_cents: invoice.tax_cents(), fee_cents }; | |
| 363 | + | let amount = (invoice.amount_paid - extras.tax_cents - extras.fee_cents).max(0) * 10_000; | |
| 292 | 364 | if amount <= 0 { | |
| 293 | 365 | return Ok(false); | |
| 294 | 366 | } | |
| 295 | − | self.enter(workspace, EntryKind::TopUp, amount, &format!("Paid invoice {}", invoice.id), &invoice.id, None, None, None, None) | |
| 296 | − | .await?; | |
| 367 | + | let description = if extras == crate::tax::Extras::default() { | |
| 368 | + | format!("Paid invoice {}", invoice.id) | |
| 369 | + | } else { | |
| 370 | + | format!( | |
| 371 | + | "Paid invoice {} (tax {} and card fee {} paid with it)", | |
| 372 | + | invoice.id, | |
| 373 | + | crate::features::cents(extras.tax_cents * 10_000), | |
| 374 | + | crate::features::cents(extras.fee_cents * 10_000) | |
| 375 | + | ) | |
| 376 | + | }; | |
| 377 | + | self.enter(workspace, EntryKind::TopUp, amount, &description, &invoice.id, None, None, None, None).await?; | |
| 378 | + | self.record_extras(workspace, &invoice.id, invoice.payment_intent.as_deref(), extras, None).await?; | |
| 297 | 379 | // Prepaid cards pay, but never raise the limit. | |
| 298 | 380 | if let (Some(stripe), Some(charge)) = (&self.stripe, &invoice.charge) | |
| 299 | 381 | && let Ok(charge) = stripe.get::<Value>(&format!("/charges/{charge}")).await | |
| ⋯ | |||
| 325 | 407 | #[derive(Deserialize)] | |
| 326 | 408 | struct Row { | |
| 327 | 409 | workspace: String, | |
| 410 | + | #[serde(default)] | |
| 411 | + | fee_micros: i64, | |
| 328 | 412 | } | |
| 329 | 413 | let Some(row) = self | |
| 330 | 414 | .db | |
| 331 | − | .prepare("SELECT workspace FROM workspace_invoices WHERE invoice_id = ?") | |
| 415 | + | .prepare("SELECT workspace, fee_micros FROM workspace_invoices WHERE invoice_id = ?") | |
| 332 | 416 | .bind(&[invoice_id.into()])? | |
| 333 | 417 | .first::<Row>(None) | |
| 334 | 418 | .await? | |
| ⋯ | |||
| 338 | 422 | let Some(stripe) = &self.stripe else { return Ok(None) }; | |
| 339 | 423 | let invoice: StripeInvoice = stripe.get(&format!("/invoices/{invoice_id}")).await?; | |
| 340 | 424 | self.db | |
| 341 | − | .prepare("UPDATE workspace_invoices SET status = 'paid', paid_at = ? WHERE invoice_id = ?") | |
| 342 | − | .bind(&[rfc3339(now_ms()).into(), invoice_id.into()])? | |
| 425 | + | .prepare("UPDATE workspace_invoices SET status = 'paid', paid_at = ?, tax_micros = ? WHERE invoice_id = ?") | |
| 426 | + | .bind(&[rfc3339(now_ms()).into(), ((invoice.tax_cents() * 10_000) as f64).into(), invoice_id.into()])? | |
| 343 | 427 | .run() | |
| 344 | 428 | .await?; | |
| 345 | − | let credited = self.credit_invoice(&row.workspace, &invoice).await?; | |
| 429 | + | let credited = self.credit_invoice(&row.workspace, &invoice, row.fee_micros / 10_000).await?; | |
| 346 | 430 | Ok(Some(format!( | |
| 347 | 431 | "invoice {invoice_id} for {} paid{}", | |
| 348 | 432 | row.workspace, | |
| ⋯ | |||
| 381 | 465 | pdf_url: row.pdf_url, | |
| 382 | 466 | lines, | |
| 383 | 467 | created_at: row.created_at, | |
| 468 | + | fee_micros: row.fee_micros, | |
| 469 | + | tax_micros: row.tax_micros, | |
| 384 | 470 | }); | |
| 385 | 471 | } | |
| 386 | 472 | Ok(invoices) | |
| ⋯ | |||
| 419 | 505 | fn an_invoice_holds_only_its_own_lines() { | |
| 420 | 506 | let draft = draft_fields("cus_1", "acme", "month", "2026-09", "g1t usage".to_owned()); | |
| 421 | 507 | assert!(draft.contains(&("pending_invoice_items_behavior", "exclude".to_owned()))); | |
| 508 | + | // Taxed by Stripe Tax, every line at g1t's tax code, excluding tax. | |
| 509 | + | assert!(draft.contains(&("automatic_tax[enabled]", "true".to_owned()))); | |
| 510 | + | let line = item_fields("cus_1", "in_1", "acme", crate::stripe::CARD_FEE_LINE, 61); | |
| 511 | + | assert!(line.contains(&("tax_code", crate::stripe::TAX_CODE.to_owned()))); | |
| 512 | + | assert!(line.contains(&("tax_behavior", "exclusive".to_owned()))); | |
| 513 | + | // Left a draft for want of an address: asked for, never charged. | |
| 514 | + | assert!(needs_tax_location(&serde_json::json!({ "automatic_tax": { "status": "requires_location_inputs" } }))); | |
| 515 | + | assert!(!needs_tax_location(&serde_json::json!({ "automatic_tax": { "status": "complete" } }))); | |
| 422 | 516 | let item = item_fields("cus_1", "in_1", "acme", "Sandbox time", 1_234); | |
| 423 | 517 | assert!(item.contains(&("invoice", "in_1".to_owned()))); | |
| 424 | 518 | assert!(item.contains(&("amount", "1234".to_owned()))); | |
| 487 | 487 | .iter() | |
| 488 | 488 | .map(|row| (row.meter.as_str(), Price::price_for(row.cost_micros, row.markup_percent))) | |
| 489 | 489 | .collect(); | |
| 490 | + | // With the card fee on top of each monthly price, as it is charged. | |
| 491 | + | let card_fee = self.card_fee().await?; | |
| 490 | 492 | let plans: Vec<_> = g1t_contracts::billing::Feature::ALL | |
| 491 | 493 | .iter() | |
| 492 | − | .map(|feature| match feature { | |
| 493 | − | g1t_contracts::billing::Feature::Security => crate::features::security_plan_at(&book), | |
| 494 | − | _ => self.plan_at(&book), | |
| 494 | + | .map(|feature| { | |
| 495 | + | let mut plan = match feature { | |
| 496 | + | g1t_contracts::billing::Feature::Security => crate::features::security_plan_at(&book), | |
| 497 | + | _ => self.plan_at(&book), | |
| 498 | + | }; | |
| 499 | + | plan.card_fee_cents = crate::tax::fee_for(i64::from(plan.monthly_cents), &card_fee) as u32; | |
| 500 | + | plan | |
| 495 | 501 | }) | |
| 496 | 502 | .collect(); | |
| 497 | 503 | Ok(PriceBook { |
| 46 | 46 | mod stripe; | |
| 47 | 47 | mod stripe_sync; | |
| 48 | 48 | mod subscriptions; | |
| 49 | + | mod tax; | |
| 49 | 50 | mod tokens; | |
| 50 | 51 | ||
| 51 | 52 | use g1t_contracts::billing::*; | |
| ⋯ | |||
| 178 | 179 | struct CheckoutRow { | |
| 179 | 180 | workspace: String, | |
| 180 | 181 | created_by: String, | |
| 182 | + | #[serde(default)] | |
| 183 | + | fee_cents: Option<u32>, | |
| 181 | 184 | } | |
| 182 | 185 | ||
| 183 | 186 | /// A payment page started, as `checkouts` keeps it. | |
| ⋯ | |||
| 588 | 591 | } else { | |
| 589 | 592 | self.row(&workspace).await?.and_then(|row| row.customer_id) | |
| 590 | 593 | }; | |
| 591 | − | let started = match stripe.start_checkout(&workspace, a.amount_cents, customer.as_deref(), &a.return_url, bank_transfer).await { | |
| 594 | + | // By card, Stripe's fee is its own line; a bank transfer has none. | |
| 595 | + | let fee = if bank_transfer { 0 } else { self.card_fee_on(i64::from(a.amount_cents)).await? as u32 }; | |
| 596 | + | let started = match stripe.start_checkout(&workspace, a.amount_cents, fee, customer.as_deref(), &a.return_url, bank_transfer).await { | |
| 592 | 597 | // A customer saved under another Stripe account: start afresh. | |
| 593 | 598 | Err(error) if customer.is_some() && stripe::is_missing(&error) => { | |
| 594 | 599 | self.forget_customer(&workspace).await?; | |
| 595 | 600 | let customer = if bank_transfer { self.customer_for(&workspace).await.ok() } else { None }; | |
| 596 | − | stripe.start_checkout(&workspace, a.amount_cents, customer.as_deref(), &a.return_url, bank_transfer).await | |
| 601 | + | stripe.start_checkout(&workspace, a.amount_cents, fee, customer.as_deref(), &a.return_url, bank_transfer).await | |
| 597 | 602 | } | |
| 598 | 603 | other => other, | |
| 599 | 604 | }; | |
| 600 | − | self.page_opened(started, &workspace, a.amount_cents, 0, &a.actor.username, None).await | |
| 605 | + | self.page_opened(started, &workspace, a.amount_cents, fee, &a.actor.username, None).await | |
| 601 | 606 | } | |
| 602 | 607 | ||
| 603 | 608 | /// A payment page Stripe started (or refused), recorded in `checkouts` | |
| ⋯ | |||
| 647 | 652 | // A prepayment only: a plan's or a card check's page is | |
| 648 | 653 | // settled where it was started, never credited as money | |
| 649 | 654 | // paid in advance. | |
| 650 | − | "SELECT workspace, created_by FROM checkouts | |
| 655 | + | "SELECT workspace, created_by, fee_cents FROM checkouts | |
| 651 | 656 | WHERE id = ? AND workspace = ? AND status = 'open' AND feature IS NULL", | |
| 652 | 657 | ) | |
| 653 | 658 | .bind(&[a.session.as_str().into(), workspace.as_str().into()])? | |
| ⋯ | |||
| 661 | 666 | Ok(session) => session, | |
| 662 | 667 | Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))), | |
| 663 | 668 | }; | |
| 664 | − | let paid = session | |
| 665 | − | .amount_total | |
| 666 | − | .filter(|_| session.payment_status == "paid"); | |
| 667 | − | if let Some(cents) = paid { | |
| 669 | + | if session.payment_status == "paid" && session.amount_total.is_some() { | |
| 668 | 670 | // Only whoever flips it from open to paid enters the credit. | |
| 669 | 671 | let claimed = self | |
| 670 | 672 | .db | |
| ⋯ | |||
| 676 | 678 | .first::<Touched>(None) | |
| 677 | 679 | .await?; | |
| 678 | 680 | if claimed.is_some() { | |
| 679 | − | self.enter( | |
| 680 | − | &checkout.workspace, | |
| 681 | − | EntryKind::TopUp, | |
| 682 | − | i64::from(cents) * MICROS_PER_DOLLAR / 100, | |
| 683 | − | "Paid in advance", | |
| 684 | − | &session.id, | |
| 685 | − | None, | |
| 686 | − | None, | |
| 687 | − | Some(&checkout.created_by), | |
| 688 | − | session.customer.as_deref(), | |
| 689 | − | ) | |
| 690 | − | .await?; | |
| 681 | + | self.credit_prepayment(&checkout.workspace, &session, checkout.fee_cents.unwrap_or(0), &checkout.created_by).await?; | |
| 691 | 682 | } | |
| 692 | 683 | } | |
| 693 | 684 | Ok(Outcome::Ok(self.standing(&workspace).await?)) | |
| 694 | 685 | } | |
| 695 | 686 | ||
| 687 | + | /// Enters a paid prepayment page: what its lines came to before tax, | |
| 688 | + | /// less the card fee, as credit; its tax and fee kept apart. Once the | |
| 689 | + | /// page is claimed `open → paid`. The credit in cents. | |
| 690 | + | pub(crate) async fn credit_prepayment(&self, workspace: &str, session: &stripe::Session, fee_cents: u32, by: &str) -> Result<i64> { | |
| 691 | + | let fee = i64::from(fee_cents); | |
| 692 | + | let cents = tax::prepay_credit_cents(session.before_tax_cents(), fee); | |
| 693 | + | let tax_cents = session.tax_cents(); | |
| 694 | + | let description = if tax_cents > 0 || fee > 0 { | |
| 695 | + | format!( | |
| 696 | + | "Paid in advance (tax {} and card fee {} paid with it)", | |
| 697 | + | features::cents(tax_cents * 10_000), | |
| 698 | + | features::cents(fee * 10_000) | |
| 699 | + | ) | |
| 700 | + | } else { | |
| 701 | + | "Paid in advance".to_owned() | |
| 702 | + | }; | |
| 703 | + | self.enter( | |
| 704 | + | workspace, | |
| 705 | + | EntryKind::TopUp, | |
| 706 | + | cents * MICROS_PER_DOLLAR / 100, | |
| 707 | + | &description, | |
| 708 | + | &session.id, | |
| 709 | + | None, | |
| 710 | + | None, | |
| 711 | + | Some(by), | |
| 712 | + | session.customer.as_deref(), | |
| 713 | + | ) | |
| 714 | + | .await?; | |
| 715 | + | self.record_extras(workspace, &session.id, session.payment_intent.as_deref(), tax::Extras { tax_cents, fee_cents: fee }, None) | |
| 716 | + | .await?; | |
| 717 | + | Ok(cents) | |
| 718 | + | } | |
| 719 | + | ||
| 696 | 720 | /// Drops a saved customer the card processor no longer knows. | |
| 697 | 721 | pub(crate) async fn forget_customer(&self, workspace: &str) -> Result<()> { | |
| 698 | 722 | self.db | |
| ⋯ | |||
| 1287 | 1311 | "cancel_subscription" => reply(&billing.cancel_subscription(args(body)?).await?), | |
| 1288 | 1312 | "close_workspace" => reply(&billing.close_workspace(args(body)?).await?), | |
| 1289 | 1313 | "has_feature" => reply(&billing.has_feature(args(body)?).await?), | |
| 1314 | + | "free_workspaces" => reply(&billing.free_workspaces(args(body)?).await?), | |
| 1290 | 1315 | "charge_feature" => reply(&billing.charge_feature(args(body)?).await?), | |
| 1291 | 1316 | "record_sandbox" => reply(&billing.record_sandbox(args(body)?).await?), | |
| 1292 | 1317 | "limit" => reply(&billing.limit(args(body)?).await?), | |
| ⋯ | |||
| 1297 | 1322 | "admin_billing_link" => reply(&billing.admin_billing_link(args(body)?).await?), | |
| 1298 | 1323 | "admin_stripe" => reply(&billing.admin_stripe(args(body)?).await?), | |
| 1299 | 1324 | "admin_enterprise_billing" => reply(&billing.admin_enterprise_billing(args(body)?).await?), | |
| 1325 | + | "admin_enterprise_address" => reply(&billing.admin_enterprise_address(args(body)?).await?), | |
| 1300 | 1326 | "admin_invoice_enterprise" => reply(&billing.admin_invoice_enterprise(args(body)?).await?), | |
| 1301 | 1327 | "stripe_webhook" => reply(&billing.stripe_webhook(args(body)?).await?), | |
| 1302 | 1328 | "invoices" => reply(&billing.invoices(args(body)?).await?), | |
| 1738 | 1738 | let (draws, refunds) = self.credit_effects(&since, &until).await?; | |
| 1739 | 1739 | overall.credits_used_micros = draws.iter().map(|(_, d)| d.micros).sum(); | |
| 1740 | 1740 | overall.credits_refunded_micros = refunds.iter().map(|r| r.micros).sum(); | |
| 1741 | + | // Tax and card fees came in with payments but are neither cash nor | |
| 1742 | + | // revenue: balances and plan payments are credited without them | |
| 1743 | + | // (tax.rs), so cash above never holds them. Shown apart. | |
| 1744 | + | (overall.tax_collected_micros, overall.card_fees_micros) = self.extras_between(&since, &until).await?; | |
| 1741 | 1745 | let mut products: Vec<ProductMargin> = products.into_values().collect(); | |
| 1742 | 1746 | products.sort_by_key(|p| std::cmp::Reverse(p.cost_micros.max(p.value_micros))); | |
| 1743 | 1747 |
| 118 | 118 | "UPDATE spikes SET workspace = ?1 WHERE workspace = ?2", | |
| 119 | 119 | "UPDATE limit_requests SET workspace = ?1 WHERE workspace = ?2", | |
| 120 | 120 | "UPDATE plan_payments SET workspace = ?1 WHERE workspace = ?2", | |
| 121 | + | "UPDATE tax_and_fees SET workspace = ?1 WHERE workspace = ?2", | |
| 121 | 122 | // One card check per workspace; alerts sent, one per level a month. | |
| 122 | 123 | "UPDATE OR IGNORE card_checks SET workspace = ?1 WHERE workspace = ?2", | |
| 123 | 124 | "DELETE FROM card_checks WHERE workspace = ?2", | |
| ⋯ | |||
| 314 | 315 | "allowance_use", "trial_grants", "credit_grants", "storage_days", | |
| 315 | 316 | "reservations", "spikes", "limit_requests", "plan_payments", "card_checks", "alerts_sent", | |
| 316 | 317 | "package_storage_days", "pending_days", "token_usage", "price_notices", "closed_workspaces", | |
| 317 | − | "workspace_costs", "own_counts", "ai_reload", "ai_reloads", | |
| 318 | + | "workspace_costs", "own_counts", "ai_reload", "ai_reloads", "tax_and_fees", | |
| 318 | 319 | ] { | |
| 319 | 320 | assert!(all.contains(&format!("FROM {table} WHERE workspace = ?2")) | |
| 320 | 321 | || all.contains(&format!("UPDATE {table} SET")) | |
| 29 | 29 | "DELETE FROM checkouts WHERE workspace = ?1", | |
| 30 | 30 | "DELETE FROM ai_reload WHERE workspace = ?1", | |
| 31 | 31 | "DELETE FROM ai_reloads WHERE workspace = ?1", | |
| 32 | + | "DELETE FROM tax_and_fees WHERE workspace = ?1", | |
| 32 | 33 | "DELETE FROM accounts WHERE workspace = ?1", | |
| 33 | 34 | "DELETE FROM plan_payments WHERE workspace = ?1", | |
| 34 | 35 | "DELETE FROM subscriptions WHERE workspace = ?1", | |
| ⋯ | |||
| 138 | 139 | "billing_accounts", "allowance_use", "trial_grants", "credit_grants", "storage_days", "package_storage_days", | |
| 139 | 140 | "token_usage", "reservations", "spikes", "limit_requests", "plan_payments", | |
| 140 | 141 | "card_checks", "alerts_sent", "price_notices", "closed_workspaces", "workspace_costs", | |
| 141 | − | "margin_alerts", "budget_alerts", "ai_reload", "ai_reloads", | |
| 142 | + | "margin_alerts", "budget_alerts", "ai_reload", "ai_reloads", "tax_and_fees", | |
| 142 | 143 | ] { | |
| 143 | 144 | assert!(!kept.contains(&table)); | |
| 144 | 145 | assert!(all.contains(&format!("DELETE FROM {table} WHERE")), "{table}"); | |
| 74 | 74 | "AI credit" => 8, | |
| 75 | 75 | "Credits from g1t" => 9, | |
| 76 | 76 | "Refunds" => 10, | |
| 77 | + | "Tax" => 8, | |
| 78 | + | "Card processing fees" => 8, | |
| 77 | 79 | _ => 11, | |
| 78 | 80 | } | |
| 79 | 81 | } | |
| 80 | 82 | ||
| 81 | 83 | /// Payments, credits and refunds: money in, which has no price. | |
| 82 | 84 | pub(crate) fn is_money_in(kind: &str) -> bool { | |
| 83 | − | matches!(kind, "Payments" | "AI credit" | "Credits from g1t" | "Refunds") | |
| 85 | + | matches!(kind, "Payments" | "AI credit" | "Credits from g1t" | "Refunds" | "Tax" | "Card processing fees") | |
| 84 | 86 | } | |
| 85 | 87 | ||
| 86 | 88 | /// A usage line at its price: what was charged, what paid for it first, | |
| ⋯ | |||
| 189 | 191 | month: String, | |
| 190 | 192 | } | |
| 191 | 193 | ||
| 194 | + | #[derive(Deserialize)] | |
| 195 | + | struct ExtraRow { | |
| 196 | + | group_key: Option<String>, | |
| 197 | + | kind: String, | |
| 198 | + | count: u32, | |
| 199 | + | amount: Option<i64>, | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /// How the statement names a `tax_and_fees` kind. | |
| 203 | + | pub(crate) fn extra_kind(kind: &str) -> &'static str { | |
| 204 | + | if kind == "tax" { "Tax" } else { "Card processing fees" } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | /// Puts a line in its group, starting the group if it is new. | |
| 208 | + | fn add_line(groups: &mut Vec<StatementGroup>, key: String, line: StatementLine) { | |
| 209 | + | match groups.iter_mut().find(|g| g.key == key) { | |
| 210 | + | Some(group) => group.lines.push(line), | |
| 211 | + | None => groups.push(StatementGroup { | |
| 212 | + | label: if key.is_empty() { "Not one project".to_owned() } else { key.clone() }, | |
| 213 | + | key, | |
| 214 | + | lines: vec![line], | |
| 215 | + | charged_micros: 0, | |
| 216 | + | price_micros: 0, | |
| 217 | + | discount_micros: 0, | |
| 218 | + | }), | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 192 | 222 | impl Billing { | |
| 193 | 223 | pub(crate) async fn statement(&self, a: StatementArgs) -> Result<Outcome<Statement>> { | |
| 194 | 224 | let workspace = a.workspace.to_lowercase(); | |
| ⋯ | |||
| 234 | 264 | discount_micros: discount, | |
| 235 | 265 | kind: row.kind, | |
| 236 | 266 | count: row.count, | |
| 267 | + | passed_micros: 0, | |
| 237 | 268 | }; | |
| 238 | − | match groups.iter_mut().find(|g| g.key == key) { | |
| 239 | − | Some(group) => group.lines.push(line), | |
| 240 | − | None => groups.push(StatementGroup { | |
| 241 | − | label: if key.is_empty() { "Not one project".to_owned() } else { key.clone() }, | |
| 242 | − | key, | |
| 243 | − | lines: vec![line], | |
| 244 | − | charged_micros: 0, | |
| 245 | − | price_micros: 0, | |
| 246 | − | discount_micros: 0, | |
| 247 | − | }), | |
| 269 | + | add_line(&mut groups, key, line); | |
| 270 | + | } | |
| 271 | + | // Tax and card fees paid with the month's payments: their own | |
| 272 | + | // lines, beside the payment, never in what was charged or paid. | |
| 273 | + | let extras = self | |
| 274 | + | .db | |
| 275 | + | .prepare(format!( | |
| 276 | + | "SELECT {} AS group_key, kind, COUNT(*) AS count, SUM(amount_micros) AS amount | |
| 277 | + | FROM tax_and_fees WHERE workspace = ?1 AND created_at >= ?2 AND created_at < ?3 GROUP BY 1, 2", | |
| 278 | + | if by_project { "''" } else { "substr(created_at, 1, 10)" } | |
| 279 | + | )) | |
| 280 | + | .bind(&[workspace.as_str().into(), from.as_str().into(), until.as_str().into()])? | |
| 281 | + | .all() | |
| 282 | + | .await? | |
| 283 | + | .results::<ExtraRow>()?; | |
| 284 | + | let (mut tax_micros, mut card_fee_micros) = (0, 0); | |
| 285 | + | for extra in extras { | |
| 286 | + | let amount = extra.amount.unwrap_or(0); | |
| 287 | + | if extra.kind == "tax" { | |
| 288 | + | tax_micros += amount; | |
| 289 | + | } else { | |
| 290 | + | card_fee_micros += amount; | |
| 248 | 291 | } | |
| 292 | + | let line = StatementLine { | |
| 293 | + | kind: extra_kind(&extra.kind).to_owned(), | |
| 294 | + | count: extra.count, | |
| 295 | + | charged_micros: 0, | |
| 296 | + | cost_micros: 0, | |
| 297 | + | covered_micros: 0, | |
| 298 | + | price_micros: 0, | |
| 299 | + | discount_micros: 0, | |
| 300 | + | passed_micros: amount, | |
| 301 | + | }; | |
| 302 | + | add_line(&mut groups, extra.group_key.unwrap_or_default(), line); | |
| 249 | 303 | } | |
| 250 | 304 | for group in &mut groups { | |
| 251 | 305 | group.lines.sort_by_key(|line| kind_order(&line.kind)); | |
| ⋯ | |||
| 269 | 323 | entries: lines.map(|l| l.count).sum(), | |
| 270 | 324 | covered: covered_lines(covered), | |
| 271 | 325 | carried_micros: self.carried(&workspace, &month).await?, | |
| 326 | + | tax_micros, | |
| 327 | + | card_fee_micros, | |
| 272 | 328 | }; | |
| 273 | 329 | let months = self | |
| 274 | 330 | .db | |
| ⋯ | |||
| 437 | 493 | use super::*; | |
| 438 | 494 | ||
| 439 | 495 | #[test] | |
| 496 | + | fn tax_and_card_fees_are_their_own_lines_beside_payments_never_charges() { | |
| 497 | + | assert_eq!(extra_kind("tax"), "Tax"); | |
| 498 | + | assert_eq!(extra_kind("card_fee"), "Card processing fees"); | |
| 499 | + | for kind in ["Tax", "Card processing fees"] { | |
| 500 | + | // Money that came in with a payment: no price, no usage. | |
| 501 | + | assert!(is_money_in(kind)); | |
| 502 | + | assert_eq!(kind_order(kind), kind_order("Payments")); | |
| 503 | + | } | |
| 504 | + | let mut groups = vec![]; | |
| 505 | + | let line = |kind: &str, passed: i64| StatementLine { | |
| 506 | + | kind: kind.to_owned(), | |
| 507 | + | count: 1, | |
| 508 | + | charged_micros: 0, | |
| 509 | + | cost_micros: 0, | |
| 510 | + | covered_micros: 0, | |
| 511 | + | price_micros: 0, | |
| 512 | + | discount_micros: 0, | |
| 513 | + | passed_micros: passed, | |
| 514 | + | }; | |
| 515 | + | add_line(&mut groups, "2026-10-08".into(), line("Tax", 1_640_000)); | |
| 516 | + | add_line(&mut groups, "2026-10-08".into(), line("Card processing fees", 920_000)); | |
| 517 | + | assert_eq!(groups.len(), 1); | |
| 518 | + | assert!(groups[0].lines.iter().all(|l| l.charged_micros == 0)); | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | #[test] | |
| 440 | 522 | fn a_month_runs_to_the_first_of_the_next() { | |
| 441 | 523 | assert_eq!(month_range("2026-10"), Some(("2026-10-01".into(), "2026-11-01".into()))); | |
| 442 | 524 | assert_eq!(month_range("2026-12"), Some(("2026-12-01".into(), "2027-01-01".into()))); | |
| 23 | 23 | /// code change: read Stripe's upgrade notes for every field billing reads. | |
| 24 | 24 | pub(crate) const STRIPE_VERSION: &str = "2025-02-24.acacia"; | |
| 25 | 25 | ||
| 26 | + | /// Stripe Tax's code for what g1t sells, on every product, price and | |
| 27 | + | /// invoice line: software as a service, for business use. A card fee line | |
| 28 | + | /// carries it too, since a fee for paying for a sale is taxed as the sale. | |
| 29 | + | pub(crate) const TAX_CODE: &str = "txcd_10103001"; | |
| 30 | + | ||
| 31 | + | /// What every payment page (payment or subscription mode) asks of Stripe | |
| 32 | + | /// Tax: tax worked out on top of the price shown, from a billing address | |
| 33 | + | /// it always collects, with the buyer's tax ID if they have one. With a | |
| 34 | + | /// customer already, what was entered is saved on it, so invoices and | |
| 35 | + | /// off-session charges later find the address too. Checkout refuses | |
| 36 | + | /// `automatic_tax` for a customer with no address unless it may save one | |
| 37 | + | /// (`customer_update[address]`), and `tax_id_collection` unless it may save | |
| 38 | + | /// the name (`customer_update[name]`). | |
| 39 | + | pub(crate) fn checkout_tax_fields(customer: Option<&str>) -> Vec<(&'static str, String)> { | |
| 40 | + | let mut fields = vec![ | |
| 41 | + | ("automatic_tax[enabled]", "true".to_owned()), | |
| 42 | + | ("billing_address_collection", "required".to_owned()), | |
| 43 | + | ("tax_id_collection[enabled]", "true".to_owned()), | |
| 44 | + | ]; | |
| 45 | + | if customer.is_some() { | |
| 46 | + | fields.extend([("customer_update[address]", "auto".to_owned()), ("customer_update[name]", "auto".to_owned())]); | |
| 47 | + | } | |
| 48 | + | fields | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// Whether Stripe refused because it could not tell where the customer is | |
| 52 | + | /// for tax: no address, or not enough of one. | |
| 53 | + | pub(crate) fn is_tax_location_error(error: &Error) -> bool { | |
| 54 | + | let text = error.to_string(); | |
| 55 | + | text.contains("customer_tax_location_invalid") || text.contains("requires_location_inputs") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Whether an address is enough for Stripe Tax to place the customer: a | |
| 59 | + | /// country, and in the United States a postal code (in Canada a postal | |
| 60 | + | /// code or a province). | |
| 61 | + | pub(crate) fn address_places_customer(address: &serde_json::Value) -> bool { | |
| 62 | + | let text = |key: &str| address[key].as_str().map(str::trim).unwrap_or_default().to_owned(); | |
| 63 | + | match text("country").to_uppercase().as_str() { | |
| 64 | + | "" => false, | |
| 65 | + | "US" => !text("postal_code").is_empty(), | |
| 66 | + | "CA" => !text("postal_code").is_empty() || !text("state").is_empty(), | |
| 67 | + | _ => true, | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 26 | 71 | /// What a Stripe failure says, for the person on the page: Stripe's own | |
| 27 | 72 | /// message when it gave one (never the request or any key), else that it | |
| 28 | 73 | /// could not be reached. | |
| ⋯ | |||
| 57 | 102 | pub url: Option<String>, | |
| 58 | 103 | /// `paid` once the money has been taken. | |
| 59 | 104 | pub payment_status: String, | |
| 60 | − | /// What was paid, in cents. | |
| 105 | + | /// What was paid, in cents, tax included. | |
| 61 | 106 | pub amount_total: Option<u32>, | |
| 107 | + | /// What the lines came to before tax, in cents. | |
| 108 | + | #[serde(default)] | |
| 109 | + | pub amount_subtotal: Option<u32>, | |
| 110 | + | /// The tax Stripe added (`amount_tax`), among other totals. | |
| 111 | + | #[serde(default)] | |
| 112 | + | pub total_details: Option<TotalDetails>, | |
| 113 | + | /// For a payment page: the payment that took the money. | |
| 114 | + | #[serde(default)] | |
| 115 | + | pub payment_intent: Option<String>, | |
| 62 | 116 | pub customer: Option<String>, | |
| 63 | 117 | /// For a plan's page: the subscription it started. | |
| 64 | 118 | #[serde(default)] | |
| ⋯ | |||
| 68 | 122 | pub setup_intent: Option<String>, | |
| 69 | 123 | } | |
| 70 | 124 | ||
| 125 | + | #[derive(Default, Deserialize)] | |
| 126 | + | pub struct TotalDetails { | |
| 127 | + | #[serde(default)] | |
| 128 | + | pub amount_tax: i64, | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | impl Session { | |
| 132 | + | /// The tax Stripe added on the page, in cents. | |
| 133 | + | pub fn tax_cents(&self) -> i64 { | |
| 134 | + | self.total_details.as_ref().map_or(0, |t| t.amount_tax.max(0)) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// What the page's lines came to before tax, in cents: the total less | |
| 138 | + | /// the tax when Stripe gives no subtotal. | |
| 139 | + | pub fn before_tax_cents(&self) -> i64 { | |
| 140 | + | match self.amount_subtotal { | |
| 141 | + | Some(subtotal) => i64::from(subtotal), | |
| 142 | + | None => (i64::from(self.amount_total.unwrap_or(0)) - self.tax_cents()).max(0), | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 71 | 147 | /// A card saved and verified: what a card check found. | |
| 72 | 148 | #[derive(Debug, Deserialize)] | |
| 73 | 149 | pub struct CheckedCard { | |
| ⋯ | |||
| 78 | 154 | /// `credit`, `debit`, `prepaid` or `unknown`. | |
| 79 | 155 | pub funding: Option<String>, | |
| 80 | 156 | pub country: Option<String>, | |
| 157 | + | /// The billing address entered with the card, as Stripe keeps it. | |
| 158 | + | #[serde(default)] | |
| 159 | + | pub address: Option<serde_json::Value>, | |
| 81 | 160 | } | |
| 82 | 161 | ||
| 83 | 162 | /// g1t's settings for Stripe's hosted billing page. | |
| ⋯ | |||
| 325 | 404 | &self, | |
| 326 | 405 | workspace: &str, | |
| 327 | 406 | amount_cents: u32, | |
| 407 | + | fee_cents: u32, | |
| 328 | 408 | customer: Option<&str>, | |
| 329 | 409 | return_url: &str, | |
| 330 | 410 | bank_transfer: bool, | |
| 331 | 411 | ) -> Result<Session> { | |
| 332 | − | let fields = prepay_fields(workspace, amount_cents, customer, return_url, bank_transfer); | |
| 333 | − | let key = page_key("prepay", workspace, &format!("{amount_cents}/{bank_transfer}"), g1t_kit::now_ms()); | |
| 412 | + | let fields = prepay_fields(workspace, amount_cents, fee_cents, customer, return_url, bank_transfer); | |
| 413 | + | let key = page_key( | |
| 414 | + | "prepay", | |
| 415 | + | workspace, | |
| 416 | + | &format!("{amount_cents}/{fee_cents}/{bank_transfer}/{}", customer.unwrap_or("new")), | |
| 417 | + | g1t_kit::now_ms(), | |
| 418 | + | ); | |
| 334 | 419 | self.send(Method::Post, "/checkout/sessions", Some(form(&fields)), Some(&key)).await | |
| 335 | 420 | } | |
| 336 | 421 | ||
| 337 | − | /// Starts a page on which a feature's monthly plan is paid for by card. | |
| 422 | + | /// Starts a page on which a feature's monthly plan is paid for by card, | |
| 423 | + | /// with the card fee as a monthly line of its own. | |
| 424 | + | #[allow(clippy::too_many_arguments)] | |
| 338 | 425 | pub async fn start_subscription( | |
| 339 | 426 | &self, | |
| 340 | 427 | workspace: &str, | |
| 341 | 428 | feature: &str, | |
| 342 | 429 | title: &str, | |
| 343 | 430 | monthly_cents: u32, | |
| 431 | + | fee_cents: u32, | |
| 344 | 432 | customer: Option<&str>, | |
| 345 | 433 | return_url: &str, | |
| 346 | 434 | ) -> Result<Session> { | |
| 347 | − | let fields = subscription_fields(workspace, feature, title, monthly_cents, customer, return_url); | |
| 348 | − | let key = page_key("plan", workspace, &format!("{feature}/{monthly_cents}/{}", customer.unwrap_or("new")), g1t_kit::now_ms()); | |
| 435 | + | let fields = subscription_fields(workspace, feature, title, monthly_cents, fee_cents, customer, return_url); | |
| 436 | + | let key = page_key( | |
| 437 | + | "plan", | |
| 438 | + | workspace, | |
| 439 | + | &format!("{feature}/{monthly_cents}/{fee_cents}/{}", customer.unwrap_or("new")), | |
| 440 | + | g1t_kit::now_ms(), | |
| 441 | + | ); | |
| 349 | 442 | self.send(Method::Post, "/checkout/sessions", Some(form(&fields)), Some(&key)).await | |
| 350 | 443 | } | |
| 351 | 444 | ||
| ⋯ | |||
| 406 | 499 | pub async fn charge_saved(&self, charge: &SavedCharge<'_>) -> Result<serde_json::Value> { | |
| 407 | 500 | self.send(Method::Post, "/payment_intents", Some(form(&saved_charge_fields(charge))), Some(charge.key)).await | |
| 408 | 501 | } | |
| 502 | + | ||
| 503 | + | /// Stripe Tax's figure for an off-session charge, which no Checkout | |
| 504 | + | /// page or invoice works out: the credit and the card fee, each at | |
| 505 | + | /// g1t's tax code and excluding tax, for the customer's saved address. | |
| 506 | + | /// A customer Stripe cannot place fails with | |
| 507 | + | /// `customer_tax_location_invalid` (`is_tax_location_error`). | |
| 508 | + | pub async fn tax_calculation(&self, charge: &SavedCharge<'_>) -> Result<TaxCalculation> { | |
| 509 | + | let key = format!("{}/tax", charge.key); | |
| 510 | + | self.send(Method::Post, "/tax/calculations", Some(form(&tax_calculation_fields(charge))), Some(&key)).await | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | /// Records a calculation as a tax transaction once its payment went | |
| 514 | + | /// through, so Stripe Tax reports and files it; `reference` is the | |
| 515 | + | /// PaymentIntent. Its id, for reversing it on a refund. | |
| 516 | + | pub async fn record_tax(&self, calculation: &str, reference: &str) -> Result<String> { | |
| 517 | + | #[derive(Deserialize)] | |
| 518 | + | struct Transaction { | |
| 519 | + | id: String, | |
| 520 | + | } | |
| 521 | + | let fields = [("calculation", calculation.to_owned()), ("reference", reference.to_owned())]; | |
| 522 | + | let made: Transaction = self | |
| 523 | + | .send(Method::Post, "/tax/transactions/create_from_calculation", Some(form(&fields)), Some(&format!("tax/{reference}"))) | |
| 524 | + | .await?; | |
| 525 | + | Ok(made.id) | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | /// Reverses a tax transaction in part for a refund: `refunded_cents` of | |
| 529 | + | /// the payment, tax included, taken back across all of it. | |
| 530 | + | pub async fn reverse_tax(&self, transaction: &str, reference: &str, refunded_cents: i64) -> Result<()> { | |
| 531 | + | let _: serde_json::Value = self | |
| 532 | + | .send( | |
| 533 | + | Method::Post, | |
| 534 | + | "/tax/transactions/create_reversal", | |
| 535 | + | Some(form(&reversal_fields(transaction, reference, refunded_cents))), | |
| 536 | + | Some(&format!("tax-reversal/{reference}")), | |
| 537 | + | ) | |
| 538 | + | .await?; | |
| 539 | + | Ok(()) | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | /// Copies the billing address entered with a card onto the customer | |
| 543 | + | /// when the customer has none Stripe Tax can use, so later invoices and | |
| 544 | + | /// charges can be taxed. Never replaces an address an owner gave. | |
| 545 | + | pub async fn fill_address(&self, customer: &str, address: &serde_json::Value) -> Result<bool> { | |
| 546 | + | if !address_places_customer(address) { | |
| 547 | + | return Ok(false); | |
| 548 | + | } | |
| 549 | + | let found = self.customer(customer).await?; | |
| 550 | + | if address_places_customer(&found["address"]) { | |
| 551 | + | return Ok(false); | |
| 552 | + | } | |
| 553 | + | let _: serde_json::Value = self.post(&format!("/customers/{}", encode(customer)), &address_fields(address)).await?; | |
| 554 | + | Ok(true) | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | /// Whether the customer's address is enough for Stripe Tax. | |
| 558 | + | pub async fn customer_placed(&self, customer: &str) -> Result<bool> { | |
| 559 | + | let found = self.customer(customer).await?; | |
| 560 | + | Ok(address_places_customer(&found["address"]) || address_places_customer(&found["shipping"]["address"])) | |
| 561 | + | } | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | /// Stripe Tax's answer for an off-session charge. | |
| 565 | + | #[derive(Debug, Deserialize)] | |
| 566 | + | pub struct TaxCalculation { | |
| 567 | + | pub id: String, | |
| 568 | + | /// The tax on top, in cents. | |
| 569 | + | #[serde(default)] | |
| 570 | + | pub tax_amount_exclusive: i64, | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | /// A customer's address from an address Stripe gave. | |
| 574 | + | pub(crate) fn address_fields(address: &serde_json::Value) -> Vec<(&'static str, String)> { | |
| 575 | + | let text = |key: &str| address[key].as_str().unwrap_or_default().to_owned(); | |
| 576 | + | vec![ | |
| 577 | + | ("address[line1]", text("line1")), | |
| 578 | + | ("address[line2]", text("line2")), | |
| 579 | + | ("address[city]", text("city")), | |
| 580 | + | ("address[state]", text("state")), | |
| 581 | + | ("address[postal_code]", text("postal_code")), | |
| 582 | + | ("address[country]", text("country")), | |
| 583 | + | ] | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | /// A tax calculation's fields: the credit and the card fee as lines at | |
| 587 | + | /// g1t's tax code, prices excluding tax. | |
| 588 | + | pub(crate) fn tax_calculation_fields(c: &SavedCharge<'_>) -> Vec<(&'static str, String)> { | |
| 589 | + | let mut fields = vec![ | |
| 590 | + | ("currency", "usd".to_owned()), | |
| 591 | + | ("customer", c.customer.to_owned()), | |
| 592 | + | ("line_items[0][amount]", c.credit_cents.to_string()), | |
| 593 | + | ("line_items[0][reference]", "ai_credit".to_owned()), | |
| 594 | + | ("line_items[0][tax_code]", TAX_CODE.to_owned()), | |
| 595 | + | ("line_items[0][tax_behavior]", "exclusive".to_owned()), | |
| 596 | + | ]; | |
| 597 | + | if c.fee_cents > 0 { | |
| 598 | + | fields.extend([ | |
| 599 | + | ("line_items[1][amount]", c.fee_cents.to_string()), | |
| 600 | + | ("line_items[1][reference]", "card_fee".to_owned()), | |
| 601 | + | ("line_items[1][tax_code]", TAX_CODE.to_owned()), | |
| 602 | + | ("line_items[1][tax_behavior]", "exclusive".to_owned()), | |
| 603 | + | ]); | |
| 604 | + | } | |
| 605 | + | fields | |
| 409 | 606 | } | |
| 410 | 607 | ||
| 608 | + | /// A refund's tax reversal: a part of the payment, as a negative amount. | |
| 609 | + | pub(crate) fn reversal_fields(transaction: &str, reference: &str, refunded_cents: i64) -> Vec<(&'static str, String)> { | |
| 610 | + | vec![ | |
| 611 | + | ("mode", "partial".to_owned()), | |
| 612 | + | ("original_transaction", transaction.to_owned()), | |
| 613 | + | ("reference", reference.to_owned()), | |
| 614 | + | ("flat_amount", (-refunded_cents.abs()).to_string()), | |
| 615 | + | ] | |
| 616 | + | } | |
| 617 | + | ||
| 411 | 618 | /// An AI credit purchase, as its payment page needs it. | |
| 412 | 619 | pub struct CreditPurchase<'a> { | |
| 413 | 620 | pub workspace: &'a str, | |
| ⋯ | |||
| 424 | 631 | pub payment_method: &'a str, | |
| 425 | 632 | pub credit_cents: u32, | |
| 426 | 633 | pub fee_cents: u32, | |
| 634 | + | /// Tax on top, from `tax_calculation`. | |
| 635 | + | pub tax_cents: u32, | |
| 636 | + | /// The calculation the tax came from, kept on the payment. | |
| 637 | + | pub tax_calculation: Option<&'a str>, | |
| 427 | 638 | pub key: &'a str, | |
| 428 | 639 | } | |
| 429 | 640 | ||
| ⋯ | |||
| 468 | 679 | format!("{return_url}{separator}{name}={{CHECKOUT_SESSION_ID}}") | |
| 469 | 680 | } | |
| 470 | 681 | ||
| 471 | − | /// A prepayment page's fields. | |
| 682 | + | /// The name of the card fee's line everywhere it appears: Checkout pages, | |
| 683 | + | /// subscriptions and invoices. Revenue figures find it by this name. | |
| 684 | + | pub(crate) const CARD_FEE_LINE: &str = "Card processing fee"; | |
| 685 | + | ||
| 686 | + | /// The form keys of one line on a payment page, for the first two lines | |
| 687 | + | /// (what is bought, and the card fee). | |
| 688 | + | const LINE_KEYS: [[&str; 8]; 2] = [ | |
| 689 | + | [ | |
| 690 | + | "line_items[0][quantity]", | |
| 691 | + | "line_items[0][price_data][currency]", | |
| 692 | + | "line_items[0][price_data][unit_amount]", | |
| 693 | + | "line_items[0][price_data][tax_behavior]", | |
| 694 | + | "line_items[0][price_data][product_data][name]", | |
| 695 | + | "line_items[0][price_data][product_data][tax_code]", | |
| 696 | + | "line_items[0][price_data][product_data][description]", | |
| 697 | + | "line_items[0][price_data][recurring][interval]", | |
| 698 | + | ], | |
| 699 | + | [ | |
| 700 | + | "line_items[1][quantity]", | |
| 701 | + | "line_items[1][price_data][currency]", | |
| 702 | + | "line_items[1][price_data][unit_amount]", | |
| 703 | + | "line_items[1][price_data][tax_behavior]", | |
| 704 | + | "line_items[1][price_data][product_data][name]", | |
| 705 | + | "line_items[1][price_data][product_data][tax_code]", | |
| 706 | + | "line_items[1][price_data][product_data][description]", | |
| 707 | + | "line_items[1][price_data][recurring][interval]", | |
| 708 | + | ], | |
| 709 | + | ]; | |
| 710 | + | ||
| 711 | + | /// One line on a payment page at g1t's tax code, its price excluding tax; | |
| 712 | + | /// monthly when `monthly`. | |
| 713 | + | fn page_line(index: usize, cents: u32, name: String, description: Option<String>, monthly: bool) -> Vec<(&'static str, String)> { | |
| 714 | + | let keys = LINE_KEYS[index.min(1)]; | |
| 715 | + | let mut fields = vec![ | |
| 716 | + | (keys[0], "1".to_owned()), | |
| 717 | + | (keys[1], "usd".to_owned()), | |
| 718 | + | (keys[2], cents.to_string()), | |
| 719 | + | (keys[3], "exclusive".to_owned()), | |
| 720 | + | (keys[4], name), | |
| 721 | + | (keys[5], TAX_CODE.to_owned()), | |
| 722 | + | ]; | |
| 723 | + | if let Some(description) = description { | |
| 724 | + | fields.push((keys[6], description)); | |
| 725 | + | } | |
| 726 | + | if monthly { | |
| 727 | + | fields.push((keys[7], "month".to_owned())); | |
| 728 | + | } | |
| 729 | + | fields | |
| 730 | + | } | |
| 731 | + | ||
| 732 | + | /// The card fee's line on a payment page, when there is a fee. | |
| 733 | + | fn fee_line(fee_cents: u32, monthly: bool) -> Vec<(&'static str, String)> { | |
| 734 | + | if fee_cents == 0 { | |
| 735 | + | return vec![]; | |
| 736 | + | } | |
| 737 | + | page_line(1, fee_cents, CARD_FEE_LINE.to_owned(), Some("Stripe's fee for taking the payment by card, passed on at cost".to_owned()), monthly) | |
| 738 | + | } | |
| 739 | + | ||
| 740 | + | /// A prepayment page's fields. By card, the card fee is its own line; by | |
| 741 | + | /// bank transfer there is none. | |
| 472 | 742 | pub(crate) fn prepay_fields( | |
| 473 | 743 | workspace: &str, | |
| 474 | 744 | amount_cents: u32, | |
| 745 | + | fee_cents: u32, | |
| 475 | 746 | customer: Option<&str>, | |
| 476 | 747 | return_url: &str, | |
| 477 | 748 | bank_transfer: bool, | |
| ⋯ | |||
| 495 | 766 | ("cancel_url", return_url.to_owned()), | |
| 496 | 767 | ("client_reference_id", workspace.to_owned()), | |
| 497 | 768 | ("metadata[workspace]", workspace.to_owned()), | |
| 498 | − | ("line_items[0][quantity]", "1".to_owned()), | |
| 499 | − | ("line_items[0][price_data][currency]", "usd".to_owned()), | |
| 500 | − | ("line_items[0][price_data][unit_amount]", amount_cents.to_string()), | |
| 501 | − | ("line_items[0][price_data][product_data][name]", format!("g1t usage paid in advance for {workspace}")), | |
| 502 | 769 | ]); | |
| 770 | + | fields.extend(page_line(0, amount_cents, format!("g1t usage paid in advance for {workspace}"), None, false)); | |
| 771 | + | if !bank_transfer { | |
| 772 | + | fields.extend(fee_line(fee_cents, false)); | |
| 773 | + | } | |
| 774 | + | fields.extend(checkout_tax_fields(customer)); | |
| 503 | 775 | match customer { | |
| 504 | 776 | Some(customer) => fields.push(("customer", customer.to_owned())), | |
| 505 | 777 | None => fields.push(("customer_creation", "always".to_owned())), | |
| ⋯ | |||
| 507 | 779 | fields | |
| 508 | 780 | } | |
| 509 | 781 | ||
| 510 | − | /// A plan's page's fields. In subscription mode Stripe makes the customer | |
| 511 | − | /// itself when there is none; `customer_creation` is for payment mode only. | |
| 782 | + | /// A plan's page's fields: the plan, and the card fee as a monthly line of | |
| 783 | + | /// its own. In subscription mode Stripe makes the customer itself when | |
| 784 | + | /// there is none; `customer_creation` is for payment mode only. The | |
| 785 | + | /// subscription it starts keeps `automatic_tax`, so every renewal is taxed. | |
| 512 | 786 | pub(crate) fn subscription_fields( | |
| 513 | 787 | workspace: &str, | |
| 514 | 788 | feature: &str, | |
| 515 | 789 | title: &str, | |
| 516 | 790 | monthly_cents: u32, | |
| 791 | + | fee_cents: u32, | |
| 517 | 792 | customer: Option<&str>, | |
| 518 | 793 | return_url: &str, | |
| 519 | 794 | ) -> Vec<(&'static str, String)> { | |
| ⋯ | |||
| 527 | 802 | ("metadata[feature]", feature.to_owned()), | |
| 528 | 803 | ("subscription_data[metadata][workspace]", workspace.to_owned()), | |
| 529 | 804 | ("subscription_data[metadata][feature]", feature.to_owned()), | |
| 530 | − | ("line_items[0][quantity]", "1".to_owned()), | |
| 531 | − | ("line_items[0][price_data][currency]", "usd".to_owned()), | |
| 532 | − | ("line_items[0][price_data][unit_amount]", monthly_cents.to_string()), | |
| 533 | − | ("line_items[0][price_data][recurring][interval]", "month".to_owned()), | |
| 534 | − | ("line_items[0][price_data][product_data][name]", format!("g1t {title} for {workspace}")), | |
| 535 | 805 | ]; | |
| 806 | + | fields.extend(page_line(0, monthly_cents, format!("g1t {title} for {workspace}"), None, true)); | |
| 807 | + | fields.extend(fee_line(fee_cents, true)); | |
| 808 | + | fields.extend(checkout_tax_fields(customer)); | |
| 536 | 809 | if let Some(customer) = customer { | |
| 537 | 810 | fields.push(("customer", customer.to_owned())); | |
| 538 | 811 | } | |
| 539 | 812 | fields | |
| 540 | 813 | } | |
| 541 | 814 | ||
| 542 | − | /// A card check's page's fields: setup mode, nothing charged. Setup mode | |
| 543 | − | /// takes no line items and no amount. | |
| 815 | + | /// A card check's page's fields: setup mode, nothing charged, so nothing | |
| 816 | + | /// to tax. Setup mode takes no line items and no amount. The billing | |
| 817 | + | /// address is asked for all the same, and copied onto the customer once | |
| 818 | + | /// the card is checked (`fill_address`), so the plan and invoices that | |
| 819 | + | /// follow can be taxed. | |
| 544 | 820 | pub(crate) fn card_check_fields(workspace: &str, customer: &str, return_url: &str) -> Vec<(&'static str, String)> { | |
| 545 | 821 | vec![ | |
| 546 | 822 | ("mode", "setup".to_owned()), | |
| 547 | 823 | ("customer", customer.to_owned()), | |
| 548 | 824 | ("payment_method_types[0]", "card".to_owned()), | |
| 549 | 825 | ("payment_method_options[card][request_three_d_secure]", "any".to_owned()), | |
| 826 | + | ("billing_address_collection", "required".to_owned()), | |
| 550 | 827 | ("success_url", back_to(return_url, "card_check")), | |
| 551 | 828 | ("cancel_url", return_url.to_owned()), | |
| 552 | 829 | ("client_reference_id", workspace.to_owned()), | |
| ⋯ | |||
| 574 | 851 | ("client_reference_id", p.workspace.to_owned()), | |
| 575 | 852 | ("metadata[workspace]", p.workspace.to_owned()), | |
| 576 | 853 | ("metadata[purpose]", "ai_credit".to_owned()), | |
| 577 | − | ("line_items[0][quantity]", "1".to_owned()), | |
| 578 | − | ("line_items[0][price_data][currency]", "usd".to_owned()), | |
| 579 | − | ("line_items[0][price_data][unit_amount]", p.credit_cents.to_string()), | |
| 580 | − | ("line_items[0][price_data][product_data][name]", "g1t AI credit".to_owned()), | |
| 581 | − | ( | |
| 582 | − | "line_items[0][price_data][product_data][description]", | |
| 583 | − | format!("Prepaid credit for Agent and AI Gateway usage in {}; expires a year after purchase", p.workspace), | |
| 584 | − | ), | |
| 585 | 854 | ]; | |
| 586 | − | if p.fee_cents > 0 { | |
| 587 | − | fields.extend([ | |
| 588 | − | ("line_items[1][quantity]", "1".to_owned()), | |
| 589 | − | ("line_items[1][price_data][currency]", "usd".to_owned()), | |
| 590 | − | ("line_items[1][price_data][unit_amount]", p.fee_cents.to_string()), | |
| 591 | − | ("line_items[1][price_data][product_data][name]", "Card processing fee".to_owned()), | |
| 592 | − | ]); | |
| 593 | − | } | |
| 855 | + | fields.extend(page_line( | |
| 856 | + | 0, | |
| 857 | + | p.credit_cents, | |
| 858 | + | "g1t AI credit".to_owned(), | |
| 859 | + | Some(format!("Prepaid credit for Agent and AI Gateway usage in {}; expires a year after purchase", p.workspace)), | |
| 860 | + | false, | |
| 861 | + | )); | |
| 862 | + | fields.extend(fee_line(p.fee_cents, false)); | |
| 863 | + | fields.extend(checkout_tax_fields(p.customer)); | |
| 594 | 864 | match p.customer { | |
| 595 | 865 | Some(customer) => fields.push(("customer", customer.to_owned())), | |
| 596 | 866 | None => fields.push(("customer_creation", "always".to_owned())), | |
| ⋯ | |||
| 598 | 868 | fields | |
| 599 | 869 | } | |
| 600 | 870 | ||
| 601 | − | /// An off-session charge's fields. | |
| 871 | + | /// An off-session charge's fields: the credit, the card fee and the tax | |
| 872 | + | /// Stripe Tax worked out for them, in one payment. | |
| 602 | 873 | pub(crate) fn saved_charge_fields(c: &SavedCharge<'_>) -> Vec<(&'static str, String)> { | |
| 603 | − | vec![ | |
| 604 | − | ("amount", (c.credit_cents + c.fee_cents).to_string()), | |
| 874 | + | let mut fields = vec![ | |
| 875 | + | ("amount", (c.credit_cents + c.fee_cents + c.tax_cents).to_string()), | |
| 605 | 876 | ("currency", "usd".to_owned()), | |
| 606 | 877 | ("customer", c.customer.to_owned()), | |
| 607 | 878 | ("payment_method", c.payment_method.to_owned()), | |
| ⋯ | |||
| 612 | 883 | ("metadata[purpose]", "ai_reload".to_owned()), | |
| 613 | 884 | ("metadata[credit_cents]", c.credit_cents.to_string()), | |
| 614 | 885 | ("metadata[fee_cents]", c.fee_cents.to_string()), | |
| 615 | − | ] | |
| 886 | + | ("metadata[tax_cents]", c.tax_cents.to_string()), | |
| 887 | + | ]; | |
| 888 | + | if let Some(calculation) = c.tax_calculation { | |
| 889 | + | fields.push(("metadata[tax_calculation]", calculation.to_owned())); | |
| 890 | + | } | |
| 891 | + | fields | |
| 892 | + | } | |
| 893 | + | ||
| 894 | + | /// A Stripe invoice's tax settings, for every invoice g1t makes: worked out | |
| 895 | + | /// by Stripe Tax. | |
| 896 | + | pub(crate) fn invoice_tax_fields() -> Vec<(&'static str, String)> { | |
| 897 | + | vec![("automatic_tax[enabled]", "true".to_owned())] | |
| 898 | + | } | |
| 899 | + | ||
| 900 | + | /// An invoice item's tax settings: g1t's tax code, the amount excluding tax. | |
| 901 | + | pub(crate) fn item_tax_fields() -> Vec<(&'static str, String)> { | |
| 902 | + | vec![("tax_behavior", "exclusive".to_owned()), ("tax_code", TAX_CODE.to_owned())] | |
| 903 | + | } | |
| 904 | + | ||
| 905 | + | /// What a paid Stripe invoice comes to, as billing counts it: the tax | |
| 906 | + | /// (`tax`, in this API version), the card fee (its lines, by name), and | |
| 907 | + | /// what is left for what was sold, all in cents. | |
| 908 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 909 | + | pub(crate) struct InvoiceSplit { | |
| 910 | + | pub tax_cents: i64, | |
| 911 | + | pub fee_cents: i64, | |
| 912 | + | pub net_cents: i64, | |
| 913 | + | } | |
| 914 | + | ||
| 915 | + | /// A plan started on a saved card, as its subscription needs it. | |
| 916 | + | pub(crate) struct SavedPlan<'a> { | |
| 917 | + | pub workspace: &'a str, | |
| 918 | + | pub feature: &'a str, | |
| 919 | + | pub title: &'a str, | |
| 920 | + | pub monthly_cents: u32, | |
| 921 | + | pub fee_cents: u32, | |
| 922 | + | pub customer: &'a str, | |
| 923 | + | pub payment_method: &'a str, | |
| 616 | 924 | } | |
| 617 | 925 | ||
| 926 | + | /// A subscription's fields, started on a saved card: the plan, the card | |
| 927 | + | /// fee as a monthly item when there is one, each excluding tax, and Stripe | |
| 928 | + | /// Tax on. | |
| 929 | + | pub(crate) fn saved_subscription_fields(p: &SavedPlan<'_>, product: &str, fee_product: Option<&str>) -> Vec<(&'static str, String)> { | |
| 930 | + | let mut fields = vec![ | |
| 931 | + | ("customer", p.customer.to_owned()), | |
| 932 | + | ("default_payment_method", p.payment_method.to_owned()), | |
| 933 | + | ("payment_behavior", "error_if_incomplete".to_owned()), | |
| 934 | + | ("automatic_tax[enabled]", "true".to_owned()), | |
| 935 | + | ("items[0][price_data][currency]", "usd".to_owned()), | |
| 936 | + | ("items[0][price_data][product]", product.to_owned()), | |
| 937 | + | ("items[0][price_data][unit_amount]", p.monthly_cents.to_string()), | |
| 938 | + | ("items[0][price_data][recurring][interval]", "month".to_owned()), | |
| 939 | + | ("items[0][price_data][tax_behavior]", "exclusive".to_owned()), | |
| 940 | + | ("metadata[workspace]", p.workspace.to_owned()), | |
| 941 | + | ("metadata[feature]", p.feature.to_owned()), | |
| 942 | + | ("description", format!("{} plan for {}", p.title, p.workspace)), | |
| 943 | + | ]; | |
| 944 | + | if let (Some(fee_product), true) = (fee_product, p.fee_cents > 0) { | |
| 945 | + | fields.extend([ | |
| 946 | + | ("items[1][price_data][currency]", "usd".to_owned()), | |
| 947 | + | ("items[1][price_data][product]", fee_product.to_owned()), | |
| 948 | + | ("items[1][price_data][unit_amount]", p.fee_cents.to_string()), | |
| 949 | + | ("items[1][price_data][recurring][interval]", "month".to_owned()), | |
| 950 | + | ("items[1][price_data][tax_behavior]", "exclusive".to_owned()), | |
| 951 | + | ]); | |
| 952 | + | } | |
| 953 | + | fields | |
| 954 | + | } | |
| 955 | + | ||
| 956 | + | pub(crate) fn invoice_split(invoice: &serde_json::Value, amount_paid: i64) -> InvoiceSplit { | |
| 957 | + | let tax_cents = invoice["tax"].as_i64().unwrap_or(0).max(0); | |
| 958 | + | let fee_cents: i64 = invoice["lines"]["data"] | |
| 959 | + | .as_array() | |
| 960 | + | .map(|lines| { | |
| 961 | + | lines | |
| 962 | + | .iter() | |
| 963 | + | .filter(|line| line["description"].as_str().is_some_and(|d| d.contains(CARD_FEE_LINE))) | |
| 964 | + | .map(|line| line["amount"].as_i64().unwrap_or(0)) | |
| 965 | + | .sum() | |
| 966 | + | }) | |
| 967 | + | .unwrap_or(0); | |
| 968 | + | let fee_cents = fee_cents.max(0); | |
| 969 | + | InvoiceSplit { tax_cents, fee_cents, net_cents: (amount_paid - tax_cents - fee_cents).max(0) } | |
| 970 | + | } | |
| 971 | + | ||
| 618 | 972 | impl Stripe { | |
| 619 | 973 | /// What a card check's setup found, once it succeeded. | |
| 620 | 974 | pub async fn checked_card(&self, setup_intent: &str) -> Result<Option<CheckedCard>> { | |
| ⋯ | |||
| 632 | 986 | country: Option<String>, | |
| 633 | 987 | } | |
| 634 | 988 | #[derive(Deserialize)] | |
| 989 | + | struct Billing { | |
| 990 | + | #[serde(default)] | |
| 991 | + | address: Option<serde_json::Value>, | |
| 992 | + | } | |
| 993 | + | #[derive(Deserialize)] | |
| 635 | 994 | struct PaymentMethod { | |
| 636 | 995 | card: Option<Card>, | |
| 996 | + | #[serde(default)] | |
| 997 | + | billing_details: Option<Billing>, | |
| 637 | 998 | } | |
| 638 | 999 | let setup: Setup = self.call(Method::Get, &format!("/setup_intents/{}", encode(setup_intent)), None).await?; | |
| 639 | 1000 | let (true, Some(method)) = (setup.status == "succeeded", setup.payment_method) else { return Ok(None) }; | |
| 640 | 1001 | let found: PaymentMethod = self.call(Method::Get, &format!("/payment_methods/{}", encode(&method)), None).await?; | |
| 641 | 1002 | let card = found.card; | |
| 1003 | + | let address = found.billing_details.and_then(|b| b.address).filter(|a| a.is_object()); | |
| 642 | 1004 | Ok(Some(CheckedCard { | |
| 1005 | + | address, | |
| 643 | 1006 | payment_method: method, | |
| 644 | 1007 | fingerprint: card.as_ref().and_then(|c| c.fingerprint.clone()), | |
| 645 | 1008 | brand: card.as_ref().and_then(|c| c.brand.clone()), | |
| ⋯ | |||
| 661 | 1024 | Ok(()) | |
| 662 | 1025 | } | |
| 663 | 1026 | ||
| 664 | − | /// A feature's product at Stripe (the plan's is tagged `plan`), made | |
| 665 | − | /// the first time it is needed. | |
| 666 | − | async fn plan_product(&self, feature: &str, title: &str) -> Result<String> { | |
| 1027 | + | /// One of g1t's products at Stripe, tagged `metadata[g1t]` (the plan's | |
| 1028 | + | /// is `plan`, the card fee's `card_fee`), made the first time it is | |
| 1029 | + | /// needed, with g1t's tax code. One made before Stripe Tax was on is | |
| 1030 | + | /// given the code when it is found. | |
| 1031 | + | async fn product(&self, tag: &str, name: &str) -> Result<String> { | |
| 667 | 1032 | #[derive(Deserialize)] | |
| 668 | 1033 | struct Product { | |
| 669 | 1034 | id: String, | |
| 670 | 1035 | #[serde(default)] | |
| 671 | 1036 | metadata: Option<std::collections::HashMap<String, String>>, | |
| 1037 | + | #[serde(default)] | |
| 1038 | + | tax_code: Option<serde_json::Value>, | |
| 672 | 1039 | } | |
| 673 | 1040 | #[derive(Deserialize)] | |
| 674 | 1041 | struct List { | |
| 675 | 1042 | data: Vec<Product>, | |
| 676 | 1043 | } | |
| 677 | 1044 | let list: List = self.call(Method::Get, "/products?active=true&limit=100", None).await?; | |
| 678 | − | let ours = |p: &Product| p.metadata.as_ref().and_then(|m| m.get("g1t")).map(String::as_str) == Some(feature); | |
| 1045 | + | let ours = |p: &Product| p.metadata.as_ref().and_then(|m| m.get("g1t")).map(String::as_str) == Some(tag); | |
| 679 | 1046 | if let Some(found) = list.data.into_iter().find(ours) { | |
| 1047 | + | let coded = found.tax_code.as_ref().is_some_and(|code| code.as_str() == Some(TAX_CODE) || code["id"].as_str() == Some(TAX_CODE)); | |
| 1048 | + | if !coded { | |
| 1049 | + | let _: serde_json::Value = self | |
| 1050 | + | .call(Method::Post, &format!("/products/{}", encode(&found.id)), Some(form(&[("tax_code", TAX_CODE.to_owned())]))) | |
| 1051 | + | .await?; | |
| 1052 | + | } | |
| 680 | 1053 | return Ok(found.id); | |
| 681 | 1054 | } | |
| 682 | 1055 | let created: Product = self | |
| 683 | 1056 | .call( | |
| 684 | 1057 | Method::Post, | |
| 685 | 1058 | "/products", | |
| 686 | − | Some(form(&[("name", format!("{title} plan")), ("metadata[g1t]", feature.to_owned())])), | |
| 1059 | + | Some(form(&[("name", name.to_owned()), ("metadata[g1t]", tag.to_owned()), ("tax_code", TAX_CODE.to_owned())])), | |
| 687 | 1060 | ) | |
| 688 | 1061 | .await?; | |
| 689 | 1062 | Ok(created.id) | |
| 690 | 1063 | } | |
| 691 | 1064 | ||
| 692 | − | /// Starts the monthly plan on a saved card, at once. Fails rather than | |
| 693 | − | /// leaving it half-started when the card's bank wants the person again; | |
| 694 | − | /// the caller then sends them to Stripe's page. | |
| 1065 | + | /// Starts the monthly plan on a saved card, at once, with tax worked | |
| 1066 | + | /// out by Stripe Tax on every invoice and the card fee as a monthly item | |
| 1067 | + | /// of its own. Fails rather than leaving it half-started when the card's | |
| 1068 | + | /// bank wants the person again, or when Stripe Tax cannot place the | |
| 1069 | + | /// customer (no billing address yet); the caller then sends them to | |
| 1070 | + | /// Stripe's page, which asks for the address. | |
| 1071 | + | #[allow(clippy::too_many_arguments)] | |
| 695 | 1072 | pub async fn subscribe_with_card( | |
| 696 | 1073 | &self, | |
| 697 | 1074 | workspace: &str, | |
| 698 | 1075 | feature: &str, | |
| 699 | 1076 | title: &str, | |
| 700 | 1077 | monthly_cents: u32, | |
| 1078 | + | fee_cents: u32, | |
| 701 | 1079 | customer: &str, | |
| 702 | 1080 | payment_method: &str, | |
| 703 | 1081 | ) -> Result<StripeSubscription> { | |
| 704 | − | let product = self.plan_product(feature, title).await?; | |
| 705 | − | let fields = [ | |
| 706 | − | ("customer", customer.to_owned()), | |
| 707 | − | ("default_payment_method", payment_method.to_owned()), | |
| 708 | − | ("payment_behavior", "error_if_incomplete".to_owned()), | |
| 709 | − | ("items[0][price_data][currency]", "usd".to_owned()), | |
| 710 | − | ("items[0][price_data][product]", product), | |
| 711 | − | ("items[0][price_data][unit_amount]", monthly_cents.to_string()), | |
| 712 | − | ("items[0][price_data][recurring][interval]", "month".to_owned()), | |
| 713 | − | ("metadata[workspace]", workspace.to_owned()), | |
| 714 | − | ("metadata[feature]", feature.to_owned()), | |
| 715 | − | ("description", format!("{title} plan for {workspace}")), | |
| 716 | − | ]; | |
| 717 | − | let key = plan_key(workspace, feature, payment_method, g1t_kit::now_ms()); | |
| 1082 | + | let product = self.product(feature, &format!("{title} plan")).await?; | |
| 1083 | + | let fee_product = if fee_cents > 0 { Some(self.product("card_fee", CARD_FEE_LINE).await?) } else { None }; | |
| 1084 | + | let plan = SavedPlan { workspace, feature, title, monthly_cents, fee_cents, customer, payment_method }; | |
| 1085 | + | let fields = saved_subscription_fields(&plan, &product, fee_product.as_deref()); | |
| 1086 | + | let key = plan_key(workspace, feature, &format!("{payment_method}/{monthly_cents}/{fee_cents}"), g1t_kit::now_ms()); | |
| 718 | 1087 | self.send(Method::Post, "/subscriptions", Some(form(&fields)), Some(&key)).await | |
| 719 | 1088 | } | |
| 720 | 1089 | ||
| 1090 | + | ||
| 721 | 1091 | /// Ends a subscription now: one that never started properly. | |
| 722 | 1092 | pub async fn cancel_now(&self, id: &str) -> Result<StripeSubscription> { | |
| 723 | 1093 | self.call(Method::Delete, &format!("/subscriptions/{}", encode(id)), None).await | |
| ⋯ | |||
| 810 | 1180 | fn the_plans_page_is_a_subscription_without_payment_mode_fields() { | |
| 811 | 1181 | let url = "https://g1t.sh/acme/-/billing?plan=plan"; | |
| 812 | 1182 | for customer in [None, Some("cus_1")] { | |
| 813 | − | let fields = subscription_fields("acme", "plan", "g1t", 2_000, customer, url); | |
| 1183 | + | let fields = subscription_fields("acme", "plan", "g1t", 2_000, 92, customer, url); | |
| 814 | 1184 | assert_eq!(has(&fields, "mode").as_deref(), Some("subscription")); | |
| 815 | 1185 | // customer_creation is for payment mode; Stripe refuses it here. | |
| 816 | 1186 | assert!(has(&fields, "customer_creation").is_none()); | |
| ⋯ | |||
| 818 | 1188 | assert_eq!(has(&fields, "customer").as_deref(), customer); | |
| 819 | 1189 | assert_eq!(has(&fields, "success_url").unwrap(), "https://g1t.sh/acme/-/billing?plan=plan&session={CHECKOUT_SESSION_ID}"); | |
| 820 | 1190 | assert_eq!(has(&fields, "line_items[0][price_data][recurring][interval]").as_deref(), Some("month")); | |
| 821 | − | assert!(has(&fields, "automatic_tax[enabled]").is_none()); | |
| 1191 | + | // The card fee is a monthly line of its own. | |
| 1192 | + | assert_eq!(has(&fields, "line_items[1][price_data][unit_amount]").as_deref(), Some("92")); | |
| 1193 | + | assert_eq!(has(&fields, "line_items[1][price_data][recurring][interval]").as_deref(), Some("month")); | |
| 1194 | + | assert_eq!(has(&fields, "line_items[1][price_data][product_data][name]").as_deref(), Some(CARD_FEE_LINE)); | |
| 822 | 1195 | } | |
| 1196 | + | let fields = subscription_fields("acme", "plan", "g1t", 2_000, 0, None, url); | |
| 1197 | + | assert!(has(&fields, "line_items[1][quantity]").is_none()); | |
| 1198 | + | } | |
| 1199 | + | ||
| 1200 | + | /// Every field a page must carry for Stripe Tax: tax on top of the | |
| 1201 | + | /// price, an address always asked for, the buyer's tax ID, and each | |
| 1202 | + | /// line at g1t's tax code, excluding tax. With a customer, what is | |
| 1203 | + | /// entered is saved on it (Checkout refuses `automatic_tax` and | |
| 1204 | + | /// `tax_id_collection` for a customer otherwise). | |
| 1205 | + | fn assert_taxed_page(fields: &[(&str, String)], customer: Option<&str>, lines: usize) { | |
| 1206 | + | assert_eq!(has(fields, "automatic_tax[enabled]").as_deref(), Some("true")); | |
| 1207 | + | assert_eq!(has(fields, "billing_address_collection").as_deref(), Some("required")); | |
| 1208 | + | assert_eq!(has(fields, "tax_id_collection[enabled]").as_deref(), Some("true")); | |
| 1209 | + | let saved = if customer.is_some() { Some("auto") } else { None }; | |
| 1210 | + | assert_eq!(has(fields, "customer_update[address]").as_deref(), saved); | |
| 1211 | + | assert_eq!(has(fields, "customer_update[name]").as_deref(), saved); | |
| 1212 | + | for (line, keys) in LINE_KEYS.iter().enumerate().take(lines) { | |
| 1213 | + | assert_eq!(has(fields, keys[3]).as_deref(), Some("exclusive"), "line {line}"); | |
| 1214 | + | assert_eq!(has(fields, keys[5]).as_deref(), Some(TAX_CODE), "line {line}"); | |
| 1215 | + | } | |
| 1216 | + | // No key twice: Stripe takes the last, silently. | |
| 1217 | + | let mut names: Vec<&str> = fields.iter().map(|(n, _)| *n).collect(); | |
| 1218 | + | names.sort_unstable(); | |
| 1219 | + | let count = names.len(); | |
| 1220 | + | names.dedup(); | |
| 1221 | + | assert_eq!(names.len(), count); | |
| 1222 | + | } | |
| 1223 | + | ||
| 1224 | + | #[test] | |
| 1225 | + | fn every_payment_page_is_taxed_by_stripe_tax() { | |
| 1226 | + | let url = "https://g1t.sh/acme/-/billing"; | |
| 1227 | + | for customer in [None, Some("cus_1")] { | |
| 1228 | + | // The plan and Security and quality. | |
| 1229 | + | assert_taxed_page(&subscription_fields("acme", "plan", "g1t", 2_000, 92, customer, url), customer, 2); | |
| 1230 | + | assert_taxed_page(&subscription_fields("acme", "security", "Security and quality", 1_000, 61, customer, url), customer, 2); | |
| 1231 | + | // Prepaying by card, with its fee, and by bank transfer, without. | |
| 1232 | + | assert_taxed_page(&prepay_fields("acme", 5_000, 185, customer, url, false), customer, 2); | |
| 1233 | + | assert_taxed_page(&prepay_fields("acme", 100_000, 0, customer, url, true), customer, 1); | |
| 1234 | + | // AI credit. | |
| 1235 | + | let purchase = CreditPurchase { workspace: "acme", credit_cents: 2_500, fee_cents: 106, customer, return_url: url }; | |
| 1236 | + | assert_taxed_page(&credit_fields(&purchase), customer, 2); | |
| 1237 | + | } | |
| 1238 | + | // tax_id_collection and customer_update are for payment and | |
| 1239 | + | // subscription mode; a card check charges nothing, so it is not | |
| 1240 | + | // taxed, but it asks for the address the plan will be taxed at. | |
| 1241 | + | let check = card_check_fields("acme", "cus_1", url); | |
| 1242 | + | assert!(has(&check, "automatic_tax[enabled]").is_none()); | |
| 1243 | + | assert_eq!(has(&check, "billing_address_collection").as_deref(), Some("required")); | |
| 1244 | + | } | |
| 1245 | + | ||
| 1246 | + | #[test] | |
| 1247 | + | fn a_bank_transfer_has_no_card_fee() { | |
| 1248 | + | let fields = prepay_fields("acme", 100_000, 3_100, Some("cus_1"), "https://g1t.sh/acme/-/billing", true); | |
| 1249 | + | assert!(has(&fields, "line_items[1][quantity]").is_none()); | |
| 1250 | + | let fields = prepay_fields("acme", 5_000, 185, None, "https://g1t.sh/acme/-/billing", false); | |
| 1251 | + | assert_eq!(has(&fields, "line_items[1][price_data][unit_amount]").as_deref(), Some("185")); | |
| 1252 | + | assert_eq!(has(&fields, "line_items[1][price_data][product_data][name]").as_deref(), Some(CARD_FEE_LINE)); | |
| 1253 | + | } | |
| 1254 | + | ||
| 1255 | + | #[test] | |
| 1256 | + | fn a_plan_on_a_saved_card_is_taxed_with_its_card_fee() { | |
| 1257 | + | let plan = SavedPlan { workspace: "acme", feature: "plan", title: "g1t", monthly_cents: 2_000, fee_cents: 92, customer: "cus_1", payment_method: "pm_1" }; | |
| 1258 | + | let fields = saved_subscription_fields(&plan, "prod_plan", Some("prod_fee")); | |
| 1259 | + | assert_eq!(has(&fields, "automatic_tax[enabled]").as_deref(), Some("true")); | |
| 1260 | + | assert_eq!(has(&fields, "items[0][price_data][tax_behavior]").as_deref(), Some("exclusive")); | |
| 1261 | + | assert_eq!(has(&fields, "items[1][price_data][product]").as_deref(), Some("prod_fee")); | |
| 1262 | + | assert_eq!(has(&fields, "items[1][price_data][unit_amount]").as_deref(), Some("92")); | |
| 1263 | + | assert_eq!(has(&fields, "items[1][price_data][tax_behavior]").as_deref(), Some("exclusive")); | |
| 1264 | + | let fields = saved_subscription_fields(&SavedPlan { fee_cents: 0, ..plan }, "prod_plan", None); | |
| 1265 | + | assert!(has(&fields, "items[1][price_data][product]").is_none()); | |
| 823 | 1266 | } | |
| 824 | 1267 | ||
| 825 | 1268 | #[test] | |
| 1269 | + | fn invoices_and_their_lines_are_taxed() { | |
| 1270 | + | assert_eq!(has(&invoice_tax_fields(), "automatic_tax[enabled]").as_deref(), Some("true")); | |
| 1271 | + | let item = item_tax_fields(); | |
| 1272 | + | assert_eq!(has(&item, "tax_behavior").as_deref(), Some("exclusive")); | |
| 1273 | + | assert_eq!(has(&item, "tax_code").as_deref(), Some(TAX_CODE)); | |
| 1274 | + | } | |
| 1275 | + | ||
| 1276 | + | #[test] | |
| 1277 | + | fn an_off_session_charge_is_its_credit_fee_and_tax() { | |
| 1278 | + | let charge = SavedCharge { | |
| 1279 | + | workspace: "acme", | |
| 1280 | + | customer: "cus_1", | |
| 1281 | + | payment_method: "pm_1", | |
| 1282 | + | credit_cents: 1_600, | |
| 1283 | + | fee_cents: 78, | |
| 1284 | + | tax_cents: 134, | |
| 1285 | + | tax_calculation: Some("taxcalc_1"), | |
| 1286 | + | key: "reload/acme/2026-10/1", | |
| 1287 | + | }; | |
| 1288 | + | let calculation = tax_calculation_fields(&charge); | |
| 1289 | + | assert_eq!(has(&calculation, "customer").as_deref(), Some("cus_1")); | |
| 1290 | + | assert_eq!(has(&calculation, "line_items[0][amount]").as_deref(), Some("1600")); | |
| 1291 | + | assert_eq!(has(&calculation, "line_items[0][tax_behavior]").as_deref(), Some("exclusive")); | |
| 1292 | + | assert_eq!(has(&calculation, "line_items[0][tax_code]").as_deref(), Some(TAX_CODE)); | |
| 1293 | + | assert_eq!(has(&calculation, "line_items[1][amount]").as_deref(), Some("78")); | |
| 1294 | + | assert_eq!(has(&calculation, "line_items[1][tax_code]").as_deref(), Some(TAX_CODE)); | |
| 1295 | + | let fields = saved_charge_fields(&charge); | |
| 1296 | + | assert_eq!(has(&fields, "amount").as_deref(), Some("1812")); | |
| 1297 | + | assert_eq!(has(&fields, "metadata[tax_calculation]").as_deref(), Some("taxcalc_1")); | |
| 1298 | + | let reversal = reversal_fields("tax_1", "pi_1/re_1", 500); | |
| 1299 | + | assert_eq!(has(&reversal, "flat_amount").as_deref(), Some("-500")); | |
| 1300 | + | assert_eq!(has(&reversal, "mode").as_deref(), Some("partial")); | |
| 1301 | + | } | |
| 1302 | + | ||
| 1303 | + | #[test] | |
| 1304 | + | fn a_paid_invoice_splits_into_tax_card_fee_and_what_was_sold() { | |
| 1305 | + | let invoice = serde_json::json!({ | |
| 1306 | + | "tax": 180, | |
| 1307 | + | "lines": { "data": [ | |
| 1308 | + | { "description": "1 × g1t plan (at $20.00 / month)", "amount": 2000 }, | |
| 1309 | + | { "description": "1 × Card processing fee (at $0.92 / month)", "amount": 92 }, | |
| 1310 | + | ] } | |
| 1311 | + | }); | |
| 1312 | + | assert_eq!(invoice_split(&invoice, 2_272), InvoiceSplit { tax_cents: 180, fee_cents: 92, net_cents: 2_000 }); | |
| 1313 | + | // Before Stripe Tax: no tax, no fee, all of it sold. | |
| 1314 | + | assert_eq!(invoice_split(&serde_json::json!({ "tax": null }), 2_000).net_cents, 2_000); | |
| 1315 | + | // A page's tax and what came before it. | |
| 1316 | + | let session: Session = serde_json::from_value(serde_json::json!({ | |
| 1317 | + | "id": "cs_1", "payment_status": "paid", "amount_total": 5_585, "amount_subtotal": 5_185, | |
| 1318 | + | "total_details": { "amount_tax": 400 }, "customer": "cus_1", "payment_intent": "pi_1" | |
| 1319 | + | })) | |
| 1320 | + | .unwrap(); | |
| 1321 | + | assert_eq!((session.tax_cents(), session.before_tax_cents()), (400, 5_185)); | |
| 1322 | + | } | |
| 1323 | + | ||
| 1324 | + | #[test] | |
| 1325 | + | fn an_address_places_a_customer_for_tax() { | |
| 1326 | + | use serde_json::json; | |
| 1327 | + | assert!(address_places_customer(&json!({ "country": "DE" }))); | |
| 1328 | + | assert!(address_places_customer(&json!({ "country": "US", "postal_code": "94107" }))); | |
| 1329 | + | assert!(!address_places_customer(&json!({ "country": "US", "postal_code": "" }))); | |
| 1330 | + | assert!(address_places_customer(&json!({ "country": "CA", "state": "ON" }))); | |
| 1331 | + | assert!(!address_places_customer(&json!({ "country": "" }))); | |
| 1332 | + | assert!(!address_places_customer(&serde_json::Value::Null)); | |
| 1333 | + | let error = Error::RustError(r#"the card processor answered 400: {"error":{"code":"customer_tax_location_invalid","message":"x"}}"#.into()); | |
| 1334 | + | assert!(is_tax_location_error(&error)); | |
| 1335 | + | } | |
| 1336 | + | ||
| 1337 | + | #[test] | |
| 826 | 1338 | fn a_card_check_is_a_setup_page_with_no_amount() { | |
| 827 | 1339 | let fields = card_check_fields("acme", "cus_1", "https://g1t.sh/acme/-/billing"); | |
| 828 | 1340 | assert_eq!(has(&fields, "mode").as_deref(), Some("setup")); | |
| ⋯ | |||
| 850 | 1362 | ||
| 851 | 1363 | #[test] | |
| 852 | 1364 | fn an_auto_reload_is_one_off_session_charge() { | |
| 853 | − | let charge = SavedCharge { workspace: "acme", customer: "cus_1", payment_method: "pm_1", credit_cents: 1_600, fee_cents: 78, key: "reload/acme/2026-10/1" }; | |
| 1365 | + | let charge = SavedCharge { | |
| 1366 | + | workspace: "acme", | |
| 1367 | + | customer: "cus_1", | |
| 1368 | + | payment_method: "pm_1", | |
| 1369 | + | credit_cents: 1_600, | |
| 1370 | + | fee_cents: 78, | |
| 1371 | + | tax_cents: 0, | |
| 1372 | + | tax_calculation: None, | |
| 1373 | + | key: "reload/acme/2026-10/1", | |
| 1374 | + | }; | |
| 854 | 1375 | let fields = saved_charge_fields(&charge); | |
| 855 | 1376 | assert_eq!(has(&fields, "amount").as_deref(), Some("1678")); | |
| 856 | 1377 | assert_eq!(has(&fields, "off_session").as_deref(), Some("true")); | |
| 1 | + | //! Tax and the card processing fee: what is paid with a payment on top of | |
| 2 | + | //! what reaches the workspace's 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 | + | //! - **The card fee.** Stripe's fee, grossed up (`ai::card_fee_cents`), as a | |
| 9 | + | //! line of its own on every card payment: the plan and Security and | |
| 10 | + | //! quality (a monthly item), prepaying, AI credit, auto-reload and | |
| 11 | + | //! invoices charged to a card. Never on a bank transfer or an invoice | |
| 12 | + | //! sent to be paid (an enterprise's). Switched by the `card_fee` cost | |
| 13 | + | //! setting, on by default. Tax applies to it as to what it is paid with. | |
| 14 | + | //! - **Neither is revenue.** A payment's balance credit is what it paid | |
| 15 | + | //! less its tax and fee; each is kept in `tax_and_fees`, shown as its own | |
| 16 | + | //! line on the statement, and in sudo's Costs as tax collected, never as | |
| 17 | + | //! cash. | |
| 18 | + | //! - **No address.** When Stripe Tax cannot place a customer, g1t does not | |
| 19 | + | //! charge: it marks the account (`accounts.tax_address_needed_at`), tells | |
| 20 | + | //! the owners once, and the Billing page asks for the address. Saving | |
| 21 | + | //! billing details with one clears it. | |
| 22 | + | ||
| 23 | + | use g1t_contracts::billing::CardFee; | |
| 24 | + | use g1t_contracts::time::rfc3339; | |
| 25 | + | use g1t_kit::now_ms; | |
| 26 | + | use serde::Deserialize; | |
| 27 | + | use worker::Result; | |
| 28 | + | ||
| 29 | + | use crate::Billing; | |
| 30 | + | ||
| 31 | + | /// A payment's tax and card fee, in cents, apart from what it paid for. | |
| 32 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 33 | + | pub(crate) struct Extras { | |
| 34 | + | pub tax_cents: i64, | |
| 35 | + | pub fee_cents: i64, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// What a prepayment credits: what its lines came to before tax, less the | |
| 39 | + | /// card fee line, in cents. Never less than nothing. | |
| 40 | + | pub(crate) fn prepay_credit_cents(before_tax_cents: i64, fee_cents: i64) -> i64 { | |
| 41 | + | (before_tax_cents - fee_cents.max(0)).max(0) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// What of a refund came off the balance, and what was tax and fee given | |
| 45 | + | /// back with it: the refund split in the proportion the payment was. | |
| 46 | + | /// `extras_cents` is the payment's tax and fee together. | |
| 47 | + | pub(crate) fn refund_split(refunded_cents: i64, paid_cents: i64, tax_cents: i64, fee_cents: i64) -> (i64, i64, i64) { | |
| 48 | + | if paid_cents <= 0 || refunded_cents <= 0 { | |
| 49 | + | return (refunded_cents.max(0), 0, 0); | |
| 50 | + | } | |
| 51 | + | let refunded = refunded_cents.min(paid_cents); | |
| 52 | + | let tax = (i128::from(refunded) * i128::from(tax_cents.max(0)) / i128::from(paid_cents)) as i64; | |
| 53 | + | let fee = (i128::from(refunded) * i128::from(fee_cents.max(0)) / i128::from(paid_cents)) as i64; | |
| 54 | + | (refunded - tax - fee, tax, fee) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// The card fee on a card payment of `cents`, when the setting is on. | |
| 58 | + | pub(crate) fn fee_for(cents: i64, fee: &CardFee) -> i64 { | |
| 59 | + | if cents <= 0 { | |
| 60 | + | return 0; | |
| 61 | + | } | |
| 62 | + | i64::from(crate::ai::card_fee_cents(u32::try_from(cents).unwrap_or(u32::MAX), fee)) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// What the owners are told when Stripe Tax cannot place the workspace. | |
| 66 | + | pub(crate) fn address_needed_message(workspace: &str) -> String { | |
| 67 | + | format!( | |
| 68 | + | "Add {workspace}'s billing address under Invoice details on the Billing page (/{workspace}/-/billing#details). 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." | |
| 69 | + | ) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | #[derive(Deserialize)] | |
| 73 | + | struct Sum { | |
| 74 | + | micros: Option<f64>, | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | impl Billing { | |
| 78 | + | /// Keeps a payment's tax and card fee apart from what it paid for, once | |
| 79 | + | /// per payment and kind. | |
| 80 | + | pub(crate) async fn record_extras( | |
| 81 | + | &self, | |
| 82 | + | workspace: &str, | |
| 83 | + | reference: &str, | |
| 84 | + | payment_intent: Option<&str>, | |
| 85 | + | extras: Extras, | |
| 86 | + | tax_transaction: Option<&str>, | |
| 87 | + | ) -> Result<()> { | |
| 88 | + | let now = rfc3339(now_ms()); | |
| 89 | + | let mut writes = vec![]; | |
| 90 | + | for (kind, cents) in [("tax", extras.tax_cents), ("card_fee", extras.fee_cents)] { | |
| 91 | + | if cents == 0 { | |
| 92 | + | continue; | |
| 93 | + | } | |
| 94 | + | writes.push( | |
| 95 | + | self.db | |
| 96 | + | .prepare( | |
| 97 | + | "INSERT OR IGNORE INTO tax_and_fees (id, workspace, kind, amount_micros, reference, payment_intent, tax_transaction, created_at) | |
| 98 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?)", | |
| 99 | + | ) | |
| 100 | + | .bind(&[ | |
| 101 | + | format!("{reference}/{kind}").into(), | |
| 102 | + | workspace.into(), | |
| 103 | + | kind.into(), | |
| 104 | + | ((cents * 10_000) as f64).into(), | |
| 105 | + | reference.into(), | |
| 106 | + | crate::optional(payment_intent), | |
| 107 | + | crate::optional(if kind == "tax" { tax_transaction } else { None }), | |
| 108 | + | now.as_str().into(), | |
| 109 | + | ])?, | |
| 110 | + | ); | |
| 111 | + | } | |
| 112 | + | if !writes.is_empty() { | |
| 113 | + | self.db.batch(writes).await?; | |
| 114 | + | } | |
| 115 | + | Ok(()) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// A payment's tax and fee, as kept, by the PaymentIntent that took it | |
| 119 | + | /// or the invoice it paid; and its tax transaction, if any. | |
| 120 | + | pub(crate) async fn extras_of(&self, payment_intent: Option<&str>, invoice: Option<&str>) -> Result<(Extras, Option<String>)> { | |
| 121 | + | #[derive(Deserialize)] | |
| 122 | + | struct Row { | |
| 123 | + | kind: String, | |
| 124 | + | amount_micros: i64, | |
| 125 | + | tax_transaction: Option<String>, | |
| 126 | + | } | |
| 127 | + | let rows = self | |
| 128 | + | .db | |
| 129 | + | .prepare( | |
| 130 | + | "SELECT kind, amount_micros, tax_transaction FROM tax_and_fees | |
| 131 | + | WHERE ((payment_intent IS NOT NULL AND payment_intent = ?1) OR reference = ?2) AND reference NOT LIKE 'refund/%'", | |
| 132 | + | ) | |
| 133 | + | .bind(&[payment_intent.unwrap_or("").into(), invoice.unwrap_or("").into()])? | |
| 134 | + | .all() | |
| 135 | + | .await? | |
| 136 | + | .results::<Row>()?; | |
| 137 | + | let mut extras = Extras::default(); | |
| 138 | + | let mut transaction = None; | |
| 139 | + | for row in rows { | |
| 140 | + | match row.kind.as_str() { | |
| 141 | + | "tax" => { | |
| 142 | + | extras.tax_cents += row.amount_micros / 10_000; | |
| 143 | + | transaction = transaction.or(row.tax_transaction); | |
| 144 | + | } | |
| 145 | + | _ => extras.fee_cents += row.amount_micros / 10_000, | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | Ok((extras, transaction)) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | /// The card fee on a card payment, as the price book and the `card_fee` | |
| 152 | + | /// setting have it now. | |
| 153 | + | pub(crate) async fn card_fee_on(&self, cents: i64) -> Result<i64> { | |
| 154 | + | Ok(fee_for(cents, &self.card_fee().await?)) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// Marks that Stripe Tax could not place the workspace, and tells its | |
| 158 | + | /// owners the first time. | |
| 159 | + | pub(crate) async fn tax_address_needed(&self, workspace: &str) -> Result<()> { | |
| 160 | + | let now = rfc3339(now_ms()); | |
| 161 | + | #[derive(Deserialize)] | |
| 162 | + | struct Row { | |
| 163 | + | #[allow(dead_code)] | |
| 164 | + | workspace: String, | |
| 165 | + | } | |
| 166 | + | let first = self | |
| 167 | + | .db | |
| 168 | + | .prepare("UPDATE accounts SET tax_address_needed_at = ? WHERE workspace = ? AND tax_address_needed_at IS NULL RETURNING workspace") | |
| 169 | + | .bind(&[now.as_str().into(), workspace.into()])? | |
| 170 | + | .first::<Row>(None) | |
| 171 | + | .await? | |
| 172 | + | .is_some(); | |
| 173 | + | if first && let Some(identity) = &self.identity { | |
| 174 | + | crate::limits::notify_with( | |
| 175 | + | identity, | |
| 176 | + | workspace, | |
| 177 | + | &format!("g1t: add a billing address for {workspace}"), | |
| 178 | + | &address_needed_message(workspace), | |
| 179 | + | "Add the address", | |
| 180 | + | &format!("https://g1t.sh/{workspace}/-/billing#details"), | |
| 181 | + | "You get this because you own this workspace on g1t. Tax is explained at https://docs.g1t.sh/guides/usage-and-billing/#tax", | |
| 182 | + | ) | |
| 183 | + | .await; | |
| 184 | + | } | |
| 185 | + | Ok(()) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | /// Clears the mark once an address is saved. | |
| 189 | + | pub(crate) async fn tax_address_given(&self, workspace: &str) -> Result<()> { | |
| 190 | + | self.db | |
| 191 | + | .prepare("UPDATE accounts SET tax_address_needed_at = NULL WHERE workspace = ?") | |
| 192 | + | .bind(&[workspace.into()])? | |
| 193 | + | .run() | |
| 194 | + | .await?; | |
| 195 | + | Ok(()) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | /// When Stripe Tax last could not place the workspace, while it still | |
| 199 | + | /// cannot. | |
| 200 | + | pub(crate) async fn tax_address_needed_at(&self, workspace: &str) -> Result<Option<String>> { | |
| 201 | + | #[derive(Deserialize)] | |
| 202 | + | struct Row { | |
| 203 | + | tax_address_needed_at: Option<String>, | |
| 204 | + | } | |
| 205 | + | Ok(self | |
| 206 | + | .db | |
| 207 | + | .prepare("SELECT tax_address_needed_at FROM accounts WHERE workspace = ?") | |
| 208 | + | .bind(&[workspace.into()])? | |
| 209 | + | .first::<Row>(None) | |
| 210 | + | .await? | |
| 211 | + | .and_then(|row| row.tax_address_needed_at)) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | /// Tax and card fees kept between two days (inclusive, `YYYY-MM-DD`), | |
| 215 | + | /// in micros, for sudo: (tax, fees). | |
| 216 | + | pub(crate) async fn extras_between(&self, from: &str, to: &str) -> Result<(i64, i64)> { | |
| 217 | + | let sum = |kind: &'static str| async move { | |
| 218 | + | Ok::<i64, worker::Error>( | |
| 219 | + | self.db | |
| 220 | + | .prepare("SELECT SUM(amount_micros) AS micros FROM tax_and_fees WHERE kind = ? AND substr(created_at, 1, 10) BETWEEN ? AND ?") | |
| 221 | + | .bind(&[kind.into(), from.into(), to.into()])? | |
| 222 | + | .first::<Sum>(None) | |
| 223 | + | .await? | |
| 224 | + | .and_then(|s| s.micros) | |
| 225 | + | .unwrap_or(0.0) as i64, | |
| 226 | + | ) | |
| 227 | + | }; | |
| 228 | + | Ok((sum("tax").await?, sum("card_fee").await?)) | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | #[cfg(test)] | |
| 233 | + | mod tests { | |
| 234 | + | use super::*; | |
| 235 | + | ||
| 236 | + | fn fee(on: bool) -> CardFee { | |
| 237 | + | CardFee { on, percent_micros: 29_000.0, fixed_cents: 30 } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | #[test] | |
| 241 | + | fn a_prepayment_credits_what_it_bought_never_its_tax_or_fee() { | |
| 242 | + | // $50 bought, $1.85 card fee, $4 tax: $50 credited. | |
| 243 | + | assert_eq!(prepay_credit_cents(5_185, 185), 5_000); | |
| 244 | + | assert_eq!(prepay_credit_cents(100_000, 0), 100_000); | |
| 245 | + | assert_eq!(prepay_credit_cents(100, 500), 0); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | #[test] | |
| 249 | + | fn a_refund_gives_back_tax_and_fee_in_proportion() { | |
| 250 | + | // $54.00 paid: $50 credit, $1.85 fee, $2.15 tax; half refunded. | |
| 251 | + | let (balance, tax, fee) = refund_split(2_700, 5_400, 215, 185); | |
| 252 | + | assert_eq!(balance + tax + fee, 2_700); | |
| 253 | + | assert_eq!((tax, fee), (107, 92)); | |
| 254 | + | // A payment with neither: all of it off the balance. | |
| 255 | + | assert_eq!(refund_split(1_000, 1_000, 0, 0), (1_000, 0, 0)); | |
| 256 | + | // Never more than was paid. | |
| 257 | + | assert_eq!(refund_split(9_000, 5_400, 215, 185).0, 5_000); | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | #[test] | |
| 261 | + | fn the_card_fee_is_on_card_payments_unless_switched_off() { | |
| 262 | + | // A $20 plan: $0.91, so that $20 is left after Stripe's 2.9% + 30¢. | |
| 263 | + | assert_eq!(fee_for(2_000, &fee(true)), 91); | |
| 264 | + | assert_eq!(fee_for(2_000, &fee(false)), 0); | |
| 265 | + | assert_eq!(fee_for(0, &fee(true)), 0); | |
| 266 | + | assert_eq!(fee_for(-500, &fee(true)), 0); | |
| 267 | + | // The default is on: a setting never written reads as on. | |
| 268 | + | assert!(g1t_contracts::billing::CostSettings::default().card_fee); | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | #[test] | |
| 272 | + | fn the_owners_are_told_where_to_add_the_address() { | |
| 273 | + | let message = address_needed_message("acme"); | |
| 274 | + | assert!(message.contains("/acme/-/billing#details")); | |
| 275 | + | assert!(message.contains("tax")); | |
| 276 | + | } | |
| 277 | + | } |
| 22 | 22 | //! overdue, their work stops until it is paid. | |
| 23 | 23 | ||
| 24 | 24 | use g1t_contracts::billing::{ | |
| 25 | − | AdminEnterpriseBillingArgs, AdminInvoiceEnterpriseArgs, AdminStripeArgs, BillingAccount, EnterpriseInvoice, | |
| 25 | + | AdminEnterpriseAddressArgs, AdminEnterpriseBillingArgs, AdminInvoiceEnterpriseArgs, AdminStripeArgs, BillingAccount, EnterpriseInvoice, | |
| 26 | 26 | EntryKind, InvoiceLine, StripeEventSummary, StripeStatus, StripeWebhook, StripeWebhookArgs, | |
| 27 | 27 | }; | |
| 28 | 28 | use g1t_contracts::time::rfc3339; | |
| ⋯ | |||
| 296 | 296 | } | |
| 297 | 297 | "invoice.paid" => { | |
| 298 | 298 | if let Some(subscription) = invoice_subscription(object) { | |
| 299 | − | self.plan_paid(subscription, &text("id"), object["amount_paid"].as_i64().unwrap_or(0)).await?; | |
| 299 | + | self.plan_paid(subscription, object).await?; | |
| 300 | 300 | self.settle_subscription(subscription).await? | |
| 301 | 301 | } else if let Some(done) = self.workspace_invoice_paid(&text("id")).await? { | |
| 302 | 302 | done | |
| 303 | 303 | } else { | |
| 304 | − | self.enterprise_invoice_paid(&text("id")).await? | |
| 304 | + | self.enterprise_invoice_paid(object).await? | |
| 305 | 305 | } | |
| 306 | 306 | } | |
| 307 | 307 | "invoice.payment_failed" => match (invoice_subscription(object), object["metadata"]["g1t_workspace"].as_str()) { | |
| ⋯ | |||
| 341 | 341 | workspace: String, | |
| 342 | 342 | created_by: String, | |
| 343 | 343 | feature: Option<String>, | |
| 344 | + | #[serde(default)] | |
| 345 | + | fee_cents: Option<u32>, | |
| 344 | 346 | } | |
| 345 | 347 | let Some(open) = self | |
| 346 | 348 | .db | |
| 347 | − | .prepare("SELECT workspace, created_by, feature FROM checkouts WHERE id = ? AND status = 'open'") | |
| 349 | + | .prepare("SELECT workspace, created_by, feature, fee_cents FROM checkouts WHERE id = ? AND status = 'open'") | |
| 348 | 350 | .bind(&[session_id.into()])? | |
| 349 | 351 | .first::<Open>(None) | |
| 350 | 352 | .await? | |
| ⋯ | |||
| 382 | 384 | } | |
| 383 | 385 | match open.feature.as_deref() { | |
| 384 | 386 | None => { | |
| 385 | − | let cents = i64::from(session.amount_total.unwrap_or(0)); | |
| 386 | − | self.enter( | |
| 387 | − | &open.workspace, | |
| 388 | − | EntryKind::TopUp, | |
| 389 | − | cents * 10_000, | |
| 390 | − | "Paid in advance", | |
| 391 | − | &session.id, | |
| 392 | − | None, | |
| 393 | − | None, | |
| 394 | − | Some(&open.created_by), | |
| 395 | − | session.customer.as_deref(), | |
| 396 | − | ) | |
| 397 | − | .await?; | |
| 387 | + | let cents = self.credit_prepayment(&open.workspace, &session, open.fee_cents.unwrap_or(0), &open.created_by).await?; | |
| 398 | 388 | Ok(format!("credited {} to {}", crate::features::dollars(cents * 10_000), open.workspace)) | |
| 399 | 389 | } | |
| 400 | 390 | Some(feature) => { | |
| ⋯ | |||
| 420 | 410 | ||
| 421 | 411 | /// The plan's monthly price, paid: revenue that never goes through the | |
| 422 | 412 | /// ledger, recorded once per invoice for sudo's figures and for trust. | |
| 423 | − | async fn plan_paid(&self, subscription_id: &str, invoice_id: &str, amount_cents: i64) -> Result<()> { | |
| 413 | + | /// Only the plan's own price is revenue: the invoice's tax and card fee | |
| 414 | + | /// are kept apart (`tax_and_fees`). | |
| 415 | + | async fn plan_paid(&self, subscription_id: &str, invoice: &Value) -> Result<()> { | |
| 416 | + | let invoice_id = invoice["id"].as_str().unwrap_or_default(); | |
| 417 | + | let split = crate::stripe::invoice_split(invoice, invoice["amount_paid"].as_i64().unwrap_or(0)); | |
| 418 | + | let amount_cents = split.net_cents; | |
| 424 | 419 | if amount_cents <= 0 || invoice_id.is_empty() { | |
| 425 | 420 | return Ok(()); | |
| 426 | 421 | } | |
| 422 | + | #[derive(Deserialize)] | |
| 423 | + | struct Owner { | |
| 424 | + | workspace: String, | |
| 425 | + | } | |
| 426 | + | if let Some(owner) = self | |
| 427 | + | .db | |
| 428 | + | .prepare("SELECT workspace FROM subscriptions WHERE subscription_id = ?") | |
| 429 | + | .bind(&[subscription_id.into()])? | |
| 430 | + | .first::<Owner>(None) | |
| 431 | + | .await? | |
| 432 | + | { | |
| 433 | + | let extras = crate::tax::Extras { tax_cents: split.tax_cents, fee_cents: split.fee_cents }; | |
| 434 | + | self.record_extras(&owner.workspace, invoice_id, invoice["payment_intent"].as_str(), extras, None).await?; | |
| 435 | + | } | |
| 427 | 436 | self.db | |
| 428 | 437 | .prepare( | |
| 429 | 438 | "INSERT INTO plan_payments (invoice_id, workspace, amount_micros, paid_at) | |
| ⋯ | |||
| 520 | 529 | .await? | |
| 521 | 530 | .and_then(|s| s.micros) | |
| 522 | 531 | .unwrap_or(0); | |
| 523 | − | let new = refunded * 10_000 - already; | |
| 524 | − | if new <= 0 { | |
| 532 | + | // A refund gives back the payment's tax and card fee in proportion: | |
| 533 | + | // only the rest comes off the balance, which never held them. | |
| 534 | + | let (extras, transaction) = self.extras_of(charge["payment_intent"].as_str(), charge["invoice"].as_str()).await?; | |
| 535 | + | let paid = charge["amount"].as_i64().unwrap_or(refunded); | |
| 536 | + | let (balance_cents, tax_cents, fee_cents) = crate::tax::refund_split(refunded, paid, extras.tax_cents, extras.fee_cents); | |
| 537 | + | let (given_tax, given_fee) = self.refunded_extras(charge_id).await?; | |
| 538 | + | let new = balance_cents * 10_000 - already; | |
| 539 | + | let new_extras = crate::tax::Extras { tax_cents: -(tax_cents - given_tax), fee_cents: -(fee_cents - given_fee) }; | |
| 540 | + | if new <= 0 && new_extras == crate::tax::Extras::default() { | |
| 525 | 541 | return Ok("ignored: refund already recorded".to_owned()); | |
| 526 | 542 | } | |
| 527 | − | self.enter( | |
| 528 | − | &workspace, | |
| 529 | − | EntryKind::TopUp, | |
| 530 | − | -new, | |
| 531 | − | "Refunded to the card", | |
| 532 | − | &format!("refund/{charge_id}/{refunded}"), | |
| 533 | − | None, | |
| 534 | − | None, | |
| 535 | − | None, | |
| 536 | − | None, | |
| 537 | − | ) | |
| 538 | − | .await?; | |
| 539 | − | Ok(format!("refund of {} recorded for {workspace}", crate::features::dollars(new))) | |
| 543 | + | let reference = format!("refund/{charge_id}/{refunded}"); | |
| 544 | + | if new > 0 { | |
| 545 | + | self.enter(&workspace, EntryKind::TopUp, -new, "Refunded to the card", &reference, None, None, None, None).await?; | |
| 546 | + | } | |
| 547 | + | self.record_extras(&workspace, &reference, charge["payment_intent"].as_str(), new_extras, None).await?; | |
| 548 | + | // An off-session charge's tax was recorded by g1t (auto-reload): | |
| 549 | + | // reverse what this refund gave back. Checkout's and invoices' tax | |
| 550 | + | // Stripe Tax keeps itself. | |
| 551 | + | if let (Some(stripe), Some(transaction)) = (&self.stripe, transaction.as_deref()) { | |
| 552 | + | let given_back = new.max(0) / 10_000 - new_extras.tax_cents - new_extras.fee_cents; | |
| 553 | + | if given_back > 0 | |
| 554 | + | && let Err(error) = stripe.reverse_tax(transaction, &reference, given_back).await | |
| 555 | + | { | |
| 556 | + | worker::console_error!("{workspace}: the tax on refund {reference} was not reversed: {error}"); | |
| 557 | + | } | |
| 558 | + | } | |
| 559 | + | Ok(format!("refund of {} recorded for {workspace}", crate::features::dollars(new.max(0)))) | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | /// What earlier refunds of a charge gave back of its tax and card fee, | |
| 563 | + | /// in cents, as positive amounts. | |
| 564 | + | async fn refunded_extras(&self, charge_id: &str) -> Result<(i64, i64)> { | |
| 565 | + | #[derive(Deserialize)] | |
| 566 | + | struct Row { | |
| 567 | + | kind: String, | |
| 568 | + | micros: Option<i64>, | |
| 569 | + | } | |
| 570 | + | let rows = self | |
| 571 | + | .db | |
| 572 | + | .prepare("SELECT kind, SUM(amount_micros) AS micros FROM tax_and_fees WHERE reference LIKE ? GROUP BY kind") | |
| 573 | + | .bind(&[format!("refund/{charge_id}/%").into()])? | |
| 574 | + | .all() | |
| 575 | + | .await? | |
| 576 | + | .results::<Row>()?; | |
| 577 | + | let of = |kind: &str| rows.iter().filter(|r| r.kind == kind).map(|r| -r.micros.unwrap_or(0) / 10_000).sum::<i64>(); | |
| 578 | + | Ok((of("tax"), of("card_fee"))) | |
| 540 | 579 | } | |
| 541 | 580 | ||
| 542 | 581 | /// A disputed payment stops the workspace's work until it is resolved; | |
| ⋯ | |||
| 633 | 672 | }) | |
| 634 | 673 | } | |
| 635 | 674 | ||
| 675 | + | /// `admin_enterprise_address`: the address Stripe Tax works the | |
| 676 | + | /// enterprise's invoices out from, and its tax ID, on its customer. | |
| 677 | + | pub(crate) async fn admin_enterprise_address(&self, a: AdminEnterpriseAddressArgs) -> Result<Outcome<bool>> { | |
| 678 | + | if a.by.trim().is_empty() { | |
| 679 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Say who is changing it.")); | |
| 680 | + | } | |
| 681 | + | if let Some(why) = crate::details::address_invalid(&a.address).or_else(|| crate::details::tax_id_invalid(a.tax_id_type.as_deref(), a.tax_id.as_deref())) { | |
| 682 | + | return Ok(Outcome::fail(FailureCode::Invalid, why)); | |
| 683 | + | } | |
| 684 | + | let place = serde_json::json!({ "country": a.address.country.trim(), "postal_code": a.address.postal_code.trim(), "state": a.address.state.trim() }); | |
| 685 | + | if !crate::stripe::address_places_customer(&place) { | |
| 686 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Give at least the country, and in the US the ZIP code: Stripe Tax needs them.")); | |
| 687 | + | } | |
| 688 | + | let Some(stripe) = &self.stripe else { | |
| 689 | + | return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t.")); | |
| 690 | + | }; | |
| 691 | + | #[derive(Deserialize)] | |
| 692 | + | struct Row { | |
| 693 | + | kind: String, | |
| 694 | + | customer_id: Option<String>, | |
| 695 | + | } | |
| 696 | + | let Some(row) = self | |
| 697 | + | .db | |
| 698 | + | .prepare("SELECT kind, customer_id FROM billing_accounts WHERE id = ?") | |
| 699 | + | .bind(&[a.id.as_str().into()])? | |
| 700 | + | .first::<Row>(None) | |
| 701 | + | .await? | |
| 702 | + | .filter(|row| row.kind == "enterprise") | |
| 703 | + | else { | |
| 704 | + | return Ok(Outcome::fail(FailureCode::NotFound, "No such enterprise.")); | |
| 705 | + | }; | |
| 706 | + | let Some(customer) = row.customer_id else { | |
| 707 | + | return Ok(Outcome::fail(FailureCode::Conflict, "Set where its invoices go first: that makes its Stripe customer.")); | |
| 708 | + | }; | |
| 709 | + | let fields: Vec<(&str, String)> = vec![ | |
| 710 | + | ("address[line1]", a.address.line1.trim().to_owned()), | |
| 711 | + | ("address[line2]", a.address.line2.trim().to_owned()), | |
| 712 | + | ("address[city]", a.address.city.trim().to_owned()), | |
| 713 | + | ("address[state]", a.address.state.trim().to_owned()), | |
| 714 | + | ("address[postal_code]", a.address.postal_code.trim().to_owned()), | |
| 715 | + | ("address[country]", a.address.country.trim().to_uppercase()), | |
| 716 | + | ]; | |
| 717 | + | if let Err(error) = stripe.post::<Value>(&format!("/customers/{customer}"), &fields).await { | |
| 718 | + | return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))); | |
| 719 | + | } | |
| 720 | + | if let (Some(kind), Some(value)) = (a.tax_id_type.as_deref(), a.tax_id.as_deref()) | |
| 721 | + | && let Err(why) = crate::details::replace_tax_id(stripe, &customer, &a.id, kind, value).await | |
| 722 | + | { | |
| 723 | + | return Ok(Outcome::fail(FailureCode::Invalid, why)); | |
| 724 | + | } | |
| 725 | + | self.audit(&a.id, "billing_address", &format!("Billing address set ({})", a.address.country.trim().to_uppercase()), &a.by).await?; | |
| 726 | + | Ok(Outcome::Ok(true)) | |
| 727 | + | } | |
| 728 | + | ||
| 636 | 729 | pub(crate) async fn admin_invoice_enterprise(&self, a: AdminInvoiceEnterpriseArgs) -> Result<Outcome<EnterpriseInvoice>> { | |
| 637 | 730 | if a.by.trim().is_empty() { | |
| 638 | 731 | return Ok(Outcome::fail(FailureCode::Invalid, "Say who is sending it.")); | |
| ⋯ | |||
| 721 | 814 | if lines.is_empty() { | |
| 722 | 815 | return Ok(Err("Its workspaces owe nothing to invoice.".into())); | |
| 723 | 816 | } | |
| 724 | − | let fields = [ | |
| 817 | + | // Stripe Tax places the customer by its address; without one the | |
| 818 | + | // invoice could not be finalized, so it is not started. | |
| 819 | + | match stripe.customer_placed(&customer).await { | |
| 820 | + | Ok(true) => {} | |
| 821 | + | Ok(false) => { | |
| 822 | + | return Ok(Err(format!( | |
| 823 | + | "Add {}'s billing address first (Invoices → Billing address): Stripe needs it to work out tax.", | |
| 824 | + | account.name | |
| 825 | + | ))); | |
| 826 | + | } | |
| 827 | + | Err(error) => return Ok(Err(crate::stripe::friendly(&error))), | |
| 828 | + | } | |
| 829 | + | // Invoiced, never by card: no card fee. Tax on top, by Stripe Tax. | |
| 830 | + | let mut fields = vec![ | |
| 725 | 831 | ("customer", customer.clone()), | |
| 726 | 832 | ("collection_method", "send_invoice".to_owned()), | |
| 727 | 833 | ("days_until_due", "30".to_owned()), | |
| ⋯ | |||
| 730 | 836 | ("metadata[g1t_enterprise]", id.to_owned()), | |
| 731 | 837 | ("metadata[period]", period.to_owned()), | |
| 732 | 838 | ]; | |
| 839 | + | fields.extend(crate::stripe::invoice_tax_fields()); | |
| 733 | 840 | #[derive(Deserialize)] | |
| 734 | 841 | struct Invoice { | |
| 735 | 842 | id: String, | |
| ⋯ | |||
| 737 | 844 | hosted_invoice_url: Option<String>, | |
| 738 | 845 | #[serde(default)] | |
| 739 | 846 | amount_due: i64, | |
| 847 | + | /// The tax Stripe added, in cents (`tax`, in this API version). | |
| 848 | + | #[serde(default)] | |
| 849 | + | tax: Option<i64>, | |
| 740 | 850 | } | |
| 741 | 851 | // The draft first, keyed on what it bills, and its lines put on it: | |
| 742 | 852 | // an attempt that failed half way is found again, never billed again | |
| ⋯ | |||
| 746 | 856 | let key = enterprise_invoice_key(id, period, &keyed); | |
| 747 | 857 | let draft: Invoice = stripe.post_idempotent("/invoices", &fields, &key).await?; | |
| 748 | 858 | for (line, cents) in lines.iter().zip(¢s) { | |
| 749 | − | let fields = [ | |
| 859 | + | let mut fields = vec![ | |
| 750 | 860 | ("customer", customer.clone()), | |
| 751 | 861 | ("invoice", draft.id.clone()), | |
| 752 | 862 | ("amount", cents.to_string()), | |
| ⋯ | |||
| 754 | 864 | ("description", format!("{}: g1t usage", line.workspace)), | |
| 755 | 865 | ("metadata[workspace]", line.workspace.clone()), | |
| 756 | 866 | ]; | |
| 867 | + | fields.extend(crate::stripe::item_tax_fields()); | |
| 757 | 868 | let _: Value = stripe.post_idempotent("/invoiceitems", &fields, &format!("{key}/item/{}", line.workspace)).await?; | |
| 758 | 869 | } | |
| 759 | 870 | // A retry finds it finalized already; that is fine. | |
| 760 | 871 | let _ = stripe.post::<Value>(&format!("/invoices/{}/finalize", draft.id), &[]).await; | |
| 761 | − | let sent: Invoice = stripe.post(&format!("/invoices/{}/send", draft.id), &[]).await?; | |
| 872 | + | let sent: Invoice = match stripe.post(&format!("/invoices/{}/send", draft.id), &[]).await { | |
| 873 | + | Ok(sent) => sent, | |
| 874 | + | Err(error) if crate::stripe::is_tax_location_error(&error) => { | |
| 875 | + | return Ok(Err(format!("Stripe could not work out tax for {}: add its billing address, then send it again.", account.name))); | |
| 876 | + | } | |
| 877 | + | Err(error) => return Err(error), | |
| 878 | + | }; | |
| 762 | 879 | let now = rfc3339(now_ms()); | |
| 763 | − | let total = lines.iter().map(|l| l.amount_micros).sum::<i64>().max(sent.amount_due * 10_000); | |
| 880 | + | // What the workspaces owe, before tax: the tax is the enterprise's | |
| 881 | + | // to pay on top, never their usage, and is kept apart. | |
| 882 | + | let tax_cents = sent.tax.unwrap_or(0).max(0); | |
| 883 | + | let total = lines.iter().map(|l| l.amount_micros).sum::<i64>().max((sent.amount_due - tax_cents) * 10_000); | |
| 764 | 884 | let mut writes = vec![self | |
| 765 | 885 | .db | |
| 766 | 886 | .prepare( | |
| ⋯ | |||
| 799 | 919 | ||
| 800 | 920 | /// An enterprise invoice paid: each workspace is credited its line, and | |
| 801 | 921 | /// any stop for the invoice is lifted. | |
| 802 | − | async fn enterprise_invoice_paid(&self, invoice_id: &str) -> Result<String> { | |
| 922 | + | async fn enterprise_invoice_paid(&self, invoice: &Value) -> Result<String> { | |
| 923 | + | let invoice_id = invoice["id"].as_str().unwrap_or_default(); | |
| 803 | 924 | let claimed = self | |
| 804 | 925 | .db | |
| 805 | 926 | .prepare( | |
| ⋯ | |||
| 827 | 948 | ) | |
| 828 | 949 | .await?; | |
| 829 | 950 | } | |
| 951 | + | // Its tax is the enterprise's, paid on top of what its workspaces | |
| 952 | + | // owed, and never credited to them. | |
| 953 | + | #[derive(Deserialize)] | |
| 954 | + | struct Account { | |
| 955 | + | account_id: String, | |
| 956 | + | } | |
| 957 | + | if let Some(account) = self | |
| 958 | + | .db | |
| 959 | + | .prepare("SELECT account_id FROM enterprise_invoices WHERE invoice_id = ?") | |
| 960 | + | .bind(&[invoice_id.into()])? | |
| 961 | + | .first::<Account>(None) | |
| 962 | + | .await? | |
| 963 | + | { | |
| 964 | + | let extras = crate::tax::Extras { tax_cents: invoice["tax"].as_i64().unwrap_or(0).max(0), fee_cents: 0 }; | |
| 965 | + | self.record_extras(&account.account_id, invoice_id, invoice["payment_intent"].as_str(), extras, None).await?; | |
| 966 | + | } | |
| 830 | 967 | Ok(format!("invoice {invoice_id} paid; {} workspaces credited", lines.len())) | |
| 831 | 968 | } | |
| 832 | 969 | ||