dimesum

首页 / 博客 / 金钱

金钱

如何分摊有一道菜没有共享的分项账单

· 阅读约 6 分钟 ·

把一顿 ¥525 的晚餐平均分摊,就会有人默默为一道自己没碰过的菜付 ¥70,解决办法是一笔支出加两条规则:点名谁吃了不共享的那一行,并让税跟着消费走。

把一顿 ¥525 的晚餐平均分摊,就会有一个人多付 ¥70。三个人里有两个吃了那份 ¥200 的海鲜拼盘,第三个人没吃,可平均分摊却让三个人各付 ¥175。Dimesum 把这张账单当作一笔支出来处理:顶层按平均分,而那道 ¥200 的行只由点它的两个人承担。

大体平均、只有一行不共享的账单,是分账应用最常见的需求,而 Dimesum 最初的数据模型却无法表达它。早先的设计把 ITEMIZED 当成一种分摊类型,与 EQUALPERCENTSHARES 互斥。而真实的餐厅账单是这两者同时成立。

分项是一笔支出上的细节,而不是另一种分摊方式

在 Dimesum 里,一笔分项支出就是一笔普通支出,只是它恰好带着自己的明细行。这笔支出保留了支出原本就有的一切:总额、参与者、一种分摊类型、一个或几个付款人。在此之上,它加入了条目,也就是账单上的各行,以及调整项,也就是印在下方的税、小费、手续费和折扣。

于是有两个层级决定每一分钱。支出的分摊类型负责划分任何没有指定人的行。指定了归属人的行,正好由那些人承担,别人不承担。

决定一行如何分摊的两个层级,出自 docs/tech/18-itemized-expenses.md §1。
层级它决定什么适用于
支出分摊类型共享成本如何划分:按平均、按百分比,或按加权份额每一个没有指定归属人的条目
条目归属人谁消费了这一行,以及按什么比例仅限该行
没有指定归属人的行按支出分摊类型划分;指定了归属人的行只由它点名的人承担 层级 1:该行没有点名任何人 共享菜品 ¥300 无归属人 EQUAL 分给 3 人 Asha ¥100 Bhavna ¥100 Chetan ¥100 层级 2:该行点名了谁吃了它 荤菜 ¥200 归属人:Asha、Bhavna 它的 2 个归属人 Asha ¥100 Bhavna ¥100 Chetan ¥0
这 ¥500 食物上的两个层级。共享菜品没有点名任何人,所以 EQUAL 把它们分给三个人;海鲜那道菜点名了两个人,所以 Chetan 的食物小计止于 ¥100。

一顿 ¥525 的晚餐,吃得最少的人付 ¥105

Asha、Bhavna 和 Chetan 一起吃晚餐,顶层按平均分。共享菜品合计 ¥300。有一道海鲜 ¥200,只有 Asha 和 Bhavna 吃了。服务费是 5%,整笔 ¥525 由 Chetan 付了。

示例演算:一张账单、两个条目、一个调整项,以及每个人承担的份额。
条目金额AshaBhavnaChetan
共享菜品,无归属人¥300¥100¥100¥100
海鲜拼盘,Asha 和 Bhavna¥200¥100¥100¥0
小计¥500¥200¥200¥100
服务费 5%,按小计¥25¥10¥10¥5
合计¥525¥210¥210¥105

Chetan 付了 ¥525,自己应承担 ¥105,所以别人欠他 ¥420:Asha 欠 ¥210,Bhavna 欠 ¥210。谁付款和谁该承担是两回事。付款人恰好是几乎没怎么吃的那个人,这一点不会改变任何份额,这也是为什么 Dimesum 把付款人放在支出层级,把归属人放在条目层级。

税跟着你吃了什么走,而不是跟着你们有几个人走

Chetan 在 ¥25 服务费里承担的是 ¥5,而不是 ¥8.33。每一个调整项都按各人的条目小计比例分摊,所以食物 200:200:100 的分法同样决定了服务费的分法。按人头分摊则会悄悄让 Chetan 为一道他根本没碰的菜付税。

因此,什么都没点的人不付税,也不享受折扣。权重取的是调整前的小计,而不是滚动累计额,这也避免了每个调整项的舍入误差累积进下一个调整项的权重里。

调整项按顺序生效,所以一笔折扣会移动 GST 的计税基数

一张写着「¥1,000,先减 10%,再加 5% GST」的账单,GST 是按 ¥900 计的,GST 那一行是 ¥45。把这两步调换,GST 就按 ¥1,000 计,得出 ¥50。调整项是一个有序列表,百分比在自己所处的位置对滚动累计额生效。这两种顺序都是真实存在的账单,所以这是一个有序列表,而不是一个集合。

同一笔折扣和 GST 的两种顺序,显示各自计税的基数和各自得出的总额 先折扣 先 GST 条目小计 ¥1,000 减 ¥1,000 的 10% 折扣 ¥100 ¥900 加 ¥900 的 5% GST GST ¥45 ¥945 账单总额 ¥945 条目小计 ¥1,000 加 ¥1,000 的 5% GST GST ¥50 ¥1,050 减 ¥1,050 的 10% 折扣 ¥105 ¥945 账单总额 ¥945 同一张账单,改为直减 ¥100 而非 10% 先减 ¥100,再 GST ¥945 先 GST,再减 ¥100 ¥950
两个百分比最终都落在同一个 ¥945 上,因为乘法可以交换次序,但 GST 那一行却不同:¥45 对 ¥50。把百分比折扣换成直减 ¥100,总额也会分开,¥945 对 ¥950。这里的顺序关乎算术,而不是呈现方式。
为什么要显式存储顺序

每个调整项都带着自己的 position。如果顺序依赖于插入次序,或依赖于某个 id 生成器保持单调递增,那么在数据库恢复之后,每个人该付多少就会悄悄改变。

在打字错误和算术之间隔着三道校验。一个池子要么给出固定金额,要么给出以基点表示的费率,绝不同时给出两者,因为同时存储两者,等于存储了一个数字和它自己的推导过程。负数金额会被拒绝,错误信息会指明用 DISCOUNT 来表示减免。超过 100,000 基点的费率也会被拒绝,这个上限远高于代表 100% 的 10,000 基点,因为百分之百的减免确实是一种真实的折扣。

internal/platform/splitcalc 中的四种调整项,以及每一种对滚动累计额做了什么。
种类对滚动累计额的作用为什么它自成一类
TAX增加仅是措辞不同;计算方式完全相同
TIP增加仅是措辞不同;计算方式完全相同
FEE增加仅是措辞不同;计算方式完全相同
DISCOUNT减少唯一改变算术的种类,所以方向来自种类本身,而绝不来自某个正负号

总额来自各行,输入的总额只是一个校验和

分项账单的总额是推导出来的:条目之和加上调整项之和。各行已经说明了账单的金额,所以另外单独给出的总额会成为第二个事实来源,可能与第一个不一致。输错账单的人去修正的是某一行,那才是错误真正所在的地方,总额随之更新。修正一张已经过账的账单,要经过一次重述其分摊的编辑,这样修正会作为一个新版本落地,而不是覆盖原来的。

客户端仍然可以发送 amount_minor,这时 Dimesum 会把它当作校验和,而不是当作答案。一致意味着客户端读到的是同一张账单。不一致则会在任何钱款转移之前拦住这笔支出。

早先的版本会为给定总额未能覆盖的那部分自动添加一个「其他」行,并在差额消失时移除该行。那一行是能用的,而现在它没有了。推导总额化解了「其他」行原本要管理的问题,而那个问题之所以出现,只是因为总额被给出了两次。

为什么 EXACT 与条目不能同时使用

EXACT 直接给出每个成员的最终金额。条目则推导出每个成员的金额。两者同时存在就是过度确定:要么它们一致,那么其中一个是多余的;要么它们不一致,那么这笔支出就有两个答案,而没有任何规则说哪个胜出。Dimesum 在门口就拒绝这种组合,而不是用一条谁也记不住的优先级规则去了结它。

EQUALPERCENTSHARES 都可以与条目组合,因为每一个都是划分共享成本的规则,而不是一组最终金额。

折扣曾经在哪里丢掉一分钱

每一次分摊都使用最大余数法,而且每一次都精确守恒自己的总额。这种方法里的平局过去会判给最小的成员 id,这就把多出来的那一分钱在每一笔平均分摊的支出里都交给了群里最年长的成员;现在平局改为根据支出 id 的哈希轮转。共享行会被汇集起来一次性划分,而不是逐行划分,所以那一分零头不会在账单的每一行都落到同一个成员身上。

我们的 apportion 函数在这里有一个真实的缺陷。Go 的整数除法向零截断,所以负的总额往错误的方向取整,剩下的零头从未被分出去:−¥10.00 按 100、200、300 的权重分摊,结果回来的是 −¥9.99。丢失的一分钱会让一张本来正确的打折账单通不过自己的求和校验,而用户会被告知他们的算术错了。现在负的总额按绝对值分摊,再取负数变回去。

照账单印出来的样子录入它

把你看得见的行录进去,为那些没有共享的行点名吃过的人,再按小票印出来的顺序加上税、小费、手续费和折扣。Dimesum 会推导出总额,按每个人消费了什么来分摊每一个调整项,并把付款人排除在外。下次某一道菜的价钱是别人所点的两倍时,就把它作为单独一行加进去,并写上两个人的名字。

常见问题

聚餐时有人点了很贵的菜,账单怎么分?

把那道菜单独列成一行,并点名吃了它的人。在 Dimesum 里,账单其余部分仍按这笔支出自己的规则划分,通常是平均分,而被点名的那一行只由它的归属人承担。税和服务费随后按各人的小计分摊,所以没吃那道菜的人在两者上都付得更少。

税和小费是该平摊,还是按各人点的菜来分?

按各人点的菜来分。Dimesum 把每一个调整项都按各人的条目小计比例分摊,而不是按人头。在一顿 ¥525 的晚餐里,如果某个人吃了 ¥100 的食物,那么在 ¥25 的服务费中他承担的是 ¥5,而不是 ¥8.33。什么都没点的人则完全不用付税。

先打折还是先加税,账单金额会不一样吗?

会。调整项按顺序对滚动累计额生效,所以一张写着「¥1,000,先减 10%,再加 5% GST」的账单,GST 是按 ¥900 计的,GST 那一行是 ¥45。把 GST 放在前面,就按 ¥1,000 计,得出 ¥50。换成直减 ¥100 的折扣,总额也会不同,¥945 对 ¥950。

分项账单为什么不能用精确金额?

EXACT 直接给出每个人的最终金额,而条目又推导出这个金额,于是两者放在一起,会让一笔支出有两个答案。Dimesum 会拒绝这种组合,而不是用一条谁也记不住的优先级规则去挑出赢家。EQUAL、PERCENT 和 SHARES 都能和条目一起使用,因为每一个都是划分共享行的规则,而不是一组最终金额。

分项账单需要自己输入总额吗?

不需要。Dimesum 会把分项总额推导为条目之和加上调整项之和,因为各行已经说明了账单的金额。客户端发来的总额会被当作校验和来核对,一旦不一致,就会在钱款转移之前拦住这笔支出。改正一个错误的数字,意味着去改正它所来自的那一行。